diff --git a/.github/instructions/custom_auth_product.instructions.md b/.github/instructions/custom_auth_product.instructions.md new file mode 100644 index 0000000000..77e3126056 --- /dev/null +++ b/.github/instructions/custom_auth_product.instructions.md @@ -0,0 +1,254 @@ +--- +applyTo: "**/custom_auth/**" +--- + +# Native Authentication Product Requirements + +## Feature Overview + +The Native Authentication (Custom Auth) feature in MSAL Browser SDK provides advanced, flexible authentication flows beyond standard OAuth/OpenID Connect protocols. This feature enables modern authentication scenarios including sign-in, sign-up, self-service password reset, and comprehensive account management. + +## Core Capabilities + +### Authentication Flows + +- **Sign-in Flow**: Username + password and username + email OTP scenarios with multi-step challenge support + - Multi-factor authentication (MFA) integration with method selection and verification + - Challenge-based verification (OTP, email codes) as part of sign-in process + - Support for multiple authentication methods during sign-in +- **Sign-up Flow**: User registration with required attributes, password setup, and verification codes +- **Password Reset Flow**: Self-service password reset with secure code verification +- **Account Management**: Token retrieval, account information access, and sign-out functionality + +### Glossary / Definitions + +- Authentication Method: A credential or verification mechanism (password, email OTP; future: SMS OTP, MFA app, passkey). +- Challenge: A server-directed verification step required before completion. +- Continuation Token: Opaque, short‑lived string from service enabling the next challenge step; treated as a credential and never persisted. +- JIT (Just‑In‑Time) Registration/Enrollment: Additional mandatory step injected mid flow (e.g., required MFA setup or profile attribute capture) before completion. +- Actionable Error: Error category that can be resolved through user input or alternate flow (e.g., invalid password, user not found). +- Terminal State: A completed or failed result; no further actions allowed. + +## User Experience Requirements + +### State Machine Pattern + +- All authentication flows must follow a predictable state machine pattern +- Each user action returns a new state/result object representing the current step +- Users can determine next actions based on state type (password required, code required, completed, etc.) +- No state mutation in place - always return new state objects + +### Error Handling + +- No exceptions thrown to SDK users - all errors returned in result objects +- Helper methods for common, actionable errors (e.g., `isUserNotFound()`, `isInvalidPassword()`) +- Clear, concise error messages that don't leak sensitive information +- Structured error data for programmatic handling + +### API Design Principles + +- Self-discoverable APIs with intuitive method names +- Consistent result patterns across all flows +- Extensible design for future authentication challenges +- Backward compatibility maintained across updates + +## Security Requirements + +### Token Management + +- Secure token storage and retrieval +- Automatic token refresh when possible +- Proper token expiration handling +- Support for custom scopes and claims + +### Data Protection + +- No sensitive information in error messages +- Secure handling of continuation tokens +- Proper correlation ID management for request tracking +- Compliance with security best practices + +## Integration Requirements + +### SDK Integration + +- Seamless integration with the current `@azure/msal-browser` public APIs. +- Compatible with standard MSAL configuration patterns +- Support for custom authority configurations +- Proper telemetry and logging integration + +### Browser Compatibility + +- Support for modern browser environments +- Proper handling of network requests and responses +- Optimized bundle size with tree shaking support +- Dynamic imports for optional features + +## Extensibility Requirements + +### Future Enhancements + +- Pluggable architecture for new authentication methods +- Support for additional challenge types (SMA, Passkey, etc.) +- Configurable flow customization +- Integration points for custom validation logic + +### Developer Experience + +- Comprehensive TypeScript support +- Clear documentation and examples +- Consistent patterns across all flows +- Easy debugging and troubleshooting capabilities + +## Usage Examples + +### Sign-In Flow Example + +Below is a sample implementation demonstrating how SDK users should interact with the Native Authentication feature. This example shows the complete sign-in flow including state machine usage, error handling, and result processing. + +```typescript +import { + CustomAuthPublicClientApplication, + SignInPasswordRequiredState, + SignInCodeRequiredState, + SignInCompletedState, + AuthFlowStateBase, + CustomAuthAccountData, + SignInResult, +} from "@azure/msal-browser/custom-auth"; +import { customAuthConfig } from "./config/auth-config"; + +async function signInFlow( + username: string, + password?: string, + code?: string +): Promise { + // 1. Create the client + const client = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + // 2. Start the sign-in process + let result: SignInResult = await client.signIn({ username }); + + // 3. Handle possible states + if (result.isFailed()) { + // Handle errors using helper methods and errorData + if (result.error?.isUserNotFound()) { + showError("User not found"); + } else if (result.error?.isInvalidUsername()) { + showError("Invalid username"); + } else if (result.error?.isPasswordIncorrect()) { + showError("Password is invalid"); + } else if (result.error?.isRedirectRequired()) { + // Fallback to delegated authentication (e.g., popup) + await handleDelegatedAuth(client); + } else { + showError( + result.error?.errorData?.errorDescription || "Sign-in failed" + ); + } + return; + } + + // 4. Password required state + if (result.isPasswordRequired()) { + const submitPasswordResult = await result.state.submitPassword( + password! + ); + if (submitPasswordResult.isFailed()) { + if (submitPasswordResult.error?.isInvalidPassword()) { + showError("Incorrect password"); + } else { + showError( + submitPasswordResult.error?.errorData?.errorDescription || + "Password verification failed" + ); + } + return; + } + } + + // 5. Code required state (e.g., OTP) + if (result.isCodeRequired()) { + const submitCodeResult = await result.state.submitCode(code!); + if (submitCodeResult.isFailed()) { + if (submitCodeResult.error?.isInvalidCode()) { + showError("Invalid code"); + } else { + showError( + submitCodeResult.error?.errorData?.errorDescription || + "Code verification failed" + ); + } + return; + } + } + + // 6. Completed state + if (result.isCompleted()) { + // Success! Access account data + const account: CustomAuthAccountData = result.data; + showSuccess(`Signed in as ${account.getAccount().username}`); + return account; + } + + // 7. Handle any other unexpected state + showError("Unexpected sign-in state"); + return; +} + +function showError(message: string) { + // Display error to user (implement as needed) + console.error(message); +} + +function showSuccess(message: string) { + // Display success to user (implement as needed) + console.log(message); +} + +async function handleDelegatedAuth(client: CustomAuthPublicClientApplication) { + // Example fallback for delegated authentication (e.g., popup) + // See the Error Handling and State Machine sections for details + // ... +} +``` + +### Key Usage Patterns + +#### State Machine Interaction + +- Each authentication step returns a new state/result object +- Use type-safe state checking methods (`isPasswordRequired()`, `isCodeRequired()`, `isCompleted()`) +- Never mutate state objects - always work with returned results +- Handle all possible states in your flow logic + +#### Error Handling Approach + +- Always check `result.error` before proceeding with success logic +- Use helper methods for common errors (`isUserNotFound()`, `isInvalidPassword()`) +- Access detailed error information via `errorData` property +- No try/catch needed - all errors returned in result objects + +#### Multi-Step Flow Management + +- Start with initial authentication call (`client.signIn()`) +- Progress through states by calling methods on state objects (`submitPassword()`, `submitCode()`) +- Each step can potentially return error, continuation, or completion states +- Handle state transitions gracefully with appropriate UI updates + +#### Account Data Access + +- Successful authentication returns `CustomAuthAccountData` +- Access user information via `account.getAccount()` +- Retrieve tokens and manage authentication state +- Use account data for subsequent API calls and user experience + +### Implementation Notes + +- This sample is simplified for clarity - connect to your UI components as needed +- For production use, implement proper loading states and user feedback +- Consider implementing retry logic for network failures +- Add appropriate logging and telemetry for debugging +- Follow the same patterns for sign-up and password reset flows diff --git a/.github/instructions/custom_auth_structure.instructions.md b/.github/instructions/custom_auth_structure.instructions.md new file mode 100644 index 0000000000..6b4a029a39 --- /dev/null +++ b/.github/instructions/custom_auth_structure.instructions.md @@ -0,0 +1,306 @@ +--- +applyTo: "**/custom_auth/**" +--- + +# Native Authentication Code Structure Guide + +## Directory Structure + +``` +src/custom_auth/ +├── CustomAuthActionInputs.ts # Input types for authentication actions +├── CustomAuthConstants.ts # Feature-wide constants and enums +├── CustomAuthPublicClientApplication.ts # Main SDK entry point +├── ICustomAuthPublicClientApplication.ts# Public API interface +├── UserAccountAttributes.ts # User attribute types and utilities +├── index.ts # Barrel export file +├── configuration/ +│ └── CustomAuthConfiguration.ts # Configuration types and validation +├── controller/ +│ ├── CustomAuthStandardController.ts # Main business logic controller +│ └── ICustomAuthStandardController.ts # Controller interface +├── core/ # Shared utilities and base classes +│ ├── CustomAuthAuthority.ts # Authority logic and validation +│ ├── auth_flow/ # Base auth flow classes and JIT components +│ │ └── jit/ # JIT-specific auth flow components +│ │ ├── error_type/ # JIT error types +│ │ ├── result/ # JIT result types +│ │ └── state/ # JIT state types +│ ├── error/ # Core error types and error codes +│ ├── interaction_client/ # Base interaction client classes +│ ├── network_client/ # HTTP and API clients +│ ├── telemetry/ # Telemetry and logging +│ └── utils/ # Utility functions +├── get_account/ # Account management flow +│ ├── auth_flow/ # States, results, errors +│ └── interaction_client/ # Account-specific clients +├── operating_context/ +│ └── CustomAuthOperatingContext.ts # Operating context definition +├── reset_password/ # Password reset flow +│ ├── auth_flow/ # States, results, errors +│ └── interaction_client/ # Reset password clients +├── sign_in/ # Sign-in flow +│ ├── auth_flow/ # States, results, errors +│ └── interaction_client/ # Sign-in clients +└── sign_up/ # Sign-up flow + ├── auth_flow/ # States, results, errors + └── interaction_client/ # Sign-up clients +``` + +## File Naming Conventions + +### Class Files + +- PascalCase for class names: `CustomAuthPublicClientApplication.ts` +- Interface files prefixed with 'I': `ICustomAuthPublicClientApplication.ts` +- Base classes suffixed with 'Base': `AuthFlowErrorBase.ts` + +### Feature Folders + +- snake_case for folder names: `sign_in/`, `reset_password/`, `get_account/` +- Consistent subfolder structure: `auth_flow/`, `interaction_client/` + +### Test Files + +- Mirror source structure in `test/custom_auth/` +- Test files suffixed with `.spec.ts` +- Test utilities in `test_resources/` folder + +## Component Relationships + +### Entry Point → Controller → Client Chain + +``` +CustomAuthPublicClientApplication + ↓ delegates to +CustomAuthStandardController + ↓ creates via factory +SignInClient/SignUpClient/ResetPasswordClient + ↓ uses +CustomAuthApiClient (composed of flow-specific API clients) +``` + +### State Machine Flow + +``` +Initial State → Action → New State → Result Object + ↓ +User checks result type (isPasswordRequired, isCodeRequired, etc.) + ↓ +User calls method on state object (submitPassword, submitCode, etc.) + ↓ +New State/Result returned +``` + +## Code Organization Patterns + +### Flow-Specific Structure + +Each authentication flow (sign_in, sign_up, reset_password) follows this pattern: + +``` +{flow_name}/ +├── auth_flow/ +│ ├── error_type/ +│ │ └── {FlowName}Error.ts # Flow-specific error types +│ ├── result/ +│ │ ├── {FlowName}Result.ts # Main flow result +│ │ └── {FlowName}*Result.ts # Step-specific results +│ └── state/ +│ ├── {FlowName}State.ts # Base state class +│ ├── {FlowName}*State.ts # Step-specific states +│ └── {FlowName}StateParameters.ts # State parameter interfaces +└── interaction_client/ + ├── {FlowName}Client.ts # Main interaction client + ├── parameter/ + │ └── {FlowName}Params.ts # Client parameter types + └── result/ + └── {FlowName}ActionResult.ts # Client action result types +``` + +### Core Module Structure + +``` +core/ +├── CustomAuthAuthority.ts # Authority logic and validation +├── auth_flow/ # Base auth flow classes and shared flow components +│ ├── AuthFlowErrorBase.ts # Base error class for auth flows +│ ├── AuthFlowResultBase.ts # Base result class for auth flows +│ ├── AuthFlowState.ts # Base state class for auth flows +│ └── jit/ # JIT-specific auth flow components +│ ├── error_type/ # JIT error types +│ ├── result/ # JIT result types +│ └── state/ # JIT state types +├── error/ # Core error types and error codes +│ ├── CustomAuthError.ts # Base custom auth error +│ ├── CustomAuthApiError.ts # API-specific errors +│ ├── HttpError.ts # HTTP-specific errors +│ ├── *Error.ts # Specific error types +│ └── *ErrorCodes.ts # Error code constants +├── interaction_client/ # Base interaction client classes +│ ├── CustomAuthInteractionClientBase.ts # Base interaction client +│ ├── CustomAuthInterationClientFactory.ts # Client factory +│ └── jit/ # JIT-specific interaction clients +│ ├── JitClient.ts # JIT interaction client +│ ├── parameter/ # JIT parameter types +│ └── result/ # JIT result types +├── network_client/ # HTTP and API clients +│ ├── custom_auth_api/ # API client implementations +│ │ ├── BaseApiClient.ts # Base API client functionality +│ │ ├── CustomAuthApiClient.ts # Main API client +│ │ ├── ICustomAuthApiClient.ts # API client interface +│ │ ├── SignInApiClient.ts # Sign-in API operations +│ │ ├── SignupApiClient.ts # Sign-up API operations +│ │ ├── ResetPasswordApiClient.ts # Reset password API operations +│ │ └── types/ # API request/response types +│ └── http_client/ # HTTP abstraction layer +│ ├── FetchHttpClient.ts # Fetch-based HTTP client +│ └── IHttpClient.ts # HTTP client interface +├── telemetry/ # Telemetry and logging +│ └── PublicApiId.ts # Public API identifiers +└── utils/ # Utility functions + ├── ArgumentValidator.ts # Input validation utilities + └── UrlUtils.ts # URL manipulation utilities +``` + +## Interface Design Patterns + +### Result Object Pattern + +All operations return result objects with consistent interface: + +```typescript +interface FlowResult { + state: FlowState; + data?: ResultData; + error?: FlowError; + + // Status checking methods + isCompleted(): boolean; + isFailed(): boolean; + isPasswordRequired(): boolean; + isCodeRequired(): boolean; + // ... other state checks +} +``` + +### State Object Pattern + +State objects encapsulate flow logic and provide action methods: + +```typescript +class FlowState { + constructor(protected stateParameters: StateParameters) {} + + // Action methods that return new results + submitPassword(password: string): Promise; + submitCode(code: string): Promise; + resendCode(): Promise; +} +``` + +### Error Object Pattern + +Error objects provide helper methods for common scenarios: + +```typescript +class FlowError { + constructor(public errorData: CustomAuthError) {} + + // Helper methods for actionable errors only + isUserNotFound(): boolean; + isInvalidPassword(): boolean; + isInvalidCode(): boolean; + // No helpers for internal/service errors +} +``` + +## Import/Export Patterns + +### Barrel Exports + +Main `index.ts` exports public API surface: + +```typescript +// Public classes +export { CustomAuthPublicClientApplication } from "./CustomAuthPublicClientApplication"; + +// Public types +export type { SignInInputs, SignUpInputs } from "./CustomAuthActionInputs"; + +// Result types +export { SignInResult } from "./sign_in/auth_flow/result/SignInResult"; + +// State types (for type checking) +export { SignInPasswordRequiredState } from "./sign_in/auth_flow/state/SignInPasswordRequiredState"; +``` + +### Internal Imports + +- Use relative imports for all internal dependencies within the custom_auth feature +- Avoid circular dependencies through proper layering + +## Extension Patterns + +### Adding New Authentication Flow + +1. Create flow folder: `new_flow/` +2. Implement auth_flow structure (states, results, errors) +3. Create interaction client +4. Add API client methods +5. Update controller with new flow method +6. Add entry point method +7. Export public types in index.ts + +### Adding New Authentication Challenge + +1. Create new state class extending base state +2. Add result type for the challenge +3. Update interaction client with challenge logic +4. Add API client methods if needed +5. Update state machine transitions +6. Add error types and helper methods + +### Adding Shared Flow Support to Existing Flow + +Shared flows (like MFA, auth method registration) can be integrated into existing flows using this pattern: + +1. **Add shared flow client** to the existing flow's state parameters (e.g., `jitClient: JitClient` in `SignInStateParameters`) +2. **Add result checking methods** to the existing flow result (e.g., `isAuthMethodRegistrationRequired()`, `isRegistrationRequired()`) +3. **Update interaction client** to handle shared flow required responses and create shared flow states +4. **Reuse existing shared flow states** from `core/auth_flow/{shared_flow_name}/state/` (do not duplicate in flow folders) +5. **Add shared flow state types** to existing flow action results (e.g., adding `AuthMethodRegistrationRequiredState` in the `SignInResultState` to support JIT state) +6. **Update flow error types** to handle shared flow-related errors if needed +7. **Update flow state transitions** to handle shared flow completion and return control to the original flow + +## Testing Structure + +### Test Organization + +``` +test/custom_auth/ +├── CustomAuthPublicClientApplication.spec.ts +├── controller/ +├── core/ +├── sign_in/ +│ ├── auth_flow/ +│ │ ├── state/ +│ │ ├── result/ +│ │ └── error_type/ +│ └── interaction_client/ +├── sign_up/ +├── reset_password/ +├── get_account/ +├── integration_tests/ # End-to-end integration tests +└── test_resources/ + ├── TestModules.ts # Shared test utilities + ├── MockClients.ts # Mock implementations + └── TestData.ts # Test data constants +``` + +### Test File Patterns + +- One test file per source file +- Group tests by method/functionality +- Use descriptive test names explaining scenario +- Follow AAA pattern (Arrange, Act, Assert) diff --git a/.github/instructions/custom_auth_tech.instructions.md b/.github/instructions/custom_auth_tech.instructions.md new file mode 100644 index 0000000000..ece9672036 --- /dev/null +++ b/.github/instructions/custom_auth_tech.instructions.md @@ -0,0 +1,211 @@ +--- +applyTo: "**/custom_auth/**" +--- + +# Native Authentication Technical Standards + +## Architecture Principles + +### Layered Architecture + +The Native Authentication feature follows a strict layered architecture: + +1. **Entry Point Layer**: `CustomAuthPublicClientApplication` - Main SDK interface +2. **Controller Layer**: `CustomAuthStandardController` - Business logic coordination +3. **Interaction Client Layer**: Flow-specific clients (`SignInClient`, `SignUpClient`, etc.) +4. **Network Client Layer**: API communication (`CustomAuthApiClient`, flow-specific API clients) +5. **State/Result Layer**: State machine implementation and result objects + +### State Machine Implementation + +- All authentication flows implemented as explicit state machines +- State objects encapsulate logic and data for each step +- Result objects wrap states and provide status checking methods +- Immutable state transitions - never mutate existing state objects +- Clear state type discrimination using TypeScript discriminated unions + +## TypeScript Standards + +### Type Safety Requirements + +- Explicit type definitions for all public APIs +- Strict null checks enabled - avoid `any` type usage +- Interface-based contracts for all public APIs +- Discriminated unions for result types with multiple outcomes +- Proper generic type constraints where applicable + +### Code Organization + +- Use `as const` object types instead of enums for better tree shaking +- Define constants as flat exports rather than nested objects +- Prefer standalone functions over classes for utility functions +- Use composition patterns over inheritance for flexibility + +### Documentation Standards + +- JSDoc comments for public classes, methods, and types exposed to SDK users (exported from `index.ts`) +- Internal classes and methods may have minimal documentation focused on maintainability +- Clear parameter and return type documentation for public APIs +- Usage examples in complex API documentation +- Error handling patterns documented with examples + +## Error Handling Standards + +### Error Type Hierarchy + +Two distinct error categories: + +1. **Core Errors (Error type)**: + + - Located in `core/error/` folder + - Extend from `AuthFlowErrorBase` + - Used internally within SDK + - Error codes defined in `*ErrorCodes.ts` files + - Suberror codes defined in `SuberrorCodes.ts` for more specific error categorization + +2. **Action/Result Errors (Non-Error type)**: + - Located in respective feature folders + - Part of `AuthFlowResultBase` result objects + - Contain `errorData` property for detailed information + - Provide helper methods for actionable errors only + +### Error Propagation Rules + +- **Never throw errors from public SDK APIs** +- All errors returned in result objects with error properties +- Controllers and state objects must catch all errors and ensure no errors are thrown to SDK users +- Network and interaction clients may throw errors internally, but these must be caught at controller/state boundaries +- Helper methods only for user-actionable errors (validation, input errors) +- Internal/service errors exposed only via `errorData` property + +### Error Message Standards + +- Concise messages to minimize bundle size +- No sensitive information in error messages +- Actionable guidance where possible +- Stable error codes for programmatic handling + +## Network Client Standards + +### API Client Architecture + +- Base `CustomAuthApiClient` composes flow-specific API clients +- Each flow has dedicated API client (`SignInApiClient`, `SignupApiClient`, etc.) +- Common HTTP operations abstracted in `BaseApiClient` +- Consistent request/response handling patterns + +### HTTP Standards + +- Use `IHttpClient` interface for all HTTP operations +- Proper correlation ID handling in all requests +- Consistent header management across requests +- Structured error response handling + +### Request/Response Types + +- Strongly typed request and response interfaces +- Consistent naming conventions (`*Request`, `*Response`) +- Proper validation of API responses +- Error response standardization + +## Testing Standards + +### Unit Testing Requirements + +- **Target coverage: >85%** +- Tests mirror source code folder structure +- Each major class has dedicated test file +- Mock dependencies within custom_auth feature only +- Use real objects for external MSAL components + +### Test Organization + +- Follow Arrange-Act-Assert (AAA) pattern +- Descriptive test names explaining scenario +- Test all public methods and state transitions +- Cover all error scenarios and edge cases +- Test all error helper methods + +### Test Utilities + +- Shared test utilities in `test_resources/` folder +- Reusable mock objects and test data +- Common test configuration patterns +- Helper functions for complex test scenarios + +### Integration Testing Requirements + +- Integration tests located in `test/custom_auth/integration_tests/` folder +- Test end-to-end authentication flows with mocked API responses +- Validate complete user journeys across multiple components +- Test error scenarios and edge cases in integrated environments +- Mock API responses to simulate various authentication scenarios and error conditions + +## Performance Standards + +### Bundle Optimization + +- Minimal incremental bundle size (only code for actively used flows included) +- Full support for tree shaking and dead code elimination (no hidden side effects at module top-level) +- Efficient code splitting and lazy loading; use dynamic imports for large or optional flows/components +- Dynamic imports for large/optional features (e.g., rarely used flow-specific clients) +- Optimized for minification: keep public surface descriptive but prefer short internal identifiers; avoid unnecessary re-exports layers +- Avoid patterns that block optimization (e.g., broad dynamic `require`, runtime string-to-symbol property access, leaking `this` across modules) +- No new mandatory runtime polyfills beyond current `@azure/msal-browser` baseline +- Feature code must tree‑shake away completely if `@azure/msal-browser/custom-auth` entry point is never imported +- Consolidate shared utilities to prevent duplication across flow bundles +- Avoid duplicating logic already present in `msal-common` or `msal-browser` (reuse instead of copy) + +## Development Workflow + +### Build Requirements + +All code must pass: + +```bash +cd lib/msal-browser && npm run build:all +cd lib/msal-browser && npm run format:fix +cd lib/msal-browser && npm run lint +``` + +### Code Generation Patterns + +When adding new authentication flows: + +1. Add method to entry point and controller +2. Create new interaction client +3. Add state/result classes for each step +4. Update state machine logic +5. Add/extend network client methods +6. Create comprehensive tests +7. Update documentation + +### Dependency Management + +- Get maintainer approval (issue + sign‑off) before adding a dependency. +- Only add if the feature can’t be done within ≤200 LOC using current MSAL packages or platform APIs. +- Allowed licenses: MIT, Apache-2.0, BSD. State license in PR. +- Reuse `msal-browser` and `msal-common` components (config, logging, telemetry, cache, crypto, HTTP). Don’t create parallel versions. + +## Security Standards + +### Token Handling + +- Secure token storage using MSAL Browser cache +- Proper token expiration handling +- Secure continuation token management +- No tokens in error messages or logs + +### Data Protection + +- No PII in error messages +- Secure handling of user credentials +- Proper request correlation for audit trails +- Compliance with security best practices + +### API Security + +- Validate all input parameters +- Sanitize error messages +- Proper HTTPS enforcement +- Secure header management diff --git a/change/@azure-msal-browser-3fae7c29-2b0a-4584-8b52-3aefe0b75052.json b/change/@azure-msal-browser-3fae7c29-2b0a-4584-8b52-3aefe0b75052.json new file mode 100644 index 0000000000..1adf924c9d --- /dev/null +++ b/change/@azure-msal-browser-3fae7c29-2b0a-4584-8b52-3aefe0b75052.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Support authentication method registration during Sign-In, #8007", + "packageName": "@azure/msal-browser", + "email": "shen.jian@live.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts b/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts index ba8d7281b5..bc2ee52163 100644 --- a/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts +++ b/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts @@ -18,6 +18,7 @@ export const ChallengeType = { PASSWORD: "password", OOB: "oob", REDIRECT: "redirect", + PREVERIFIED: "preverified", } as const; export const DefaultScopes = [ diff --git a/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts index c768a2f6d4..d206e4f599 100644 --- a/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts +++ b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts @@ -29,6 +29,8 @@ import { DefaultPackageInfo } from "../CustomAuthConstants.js"; import { SIGN_IN_CODE_SEND_RESULT_TYPE, SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, + SIGN_IN_COMPLETED_RESULT_TYPE, + SIGN_IN_JIT_REQUIRED_RESULT_TYPE, } 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"; @@ -40,6 +42,7 @@ import { ICustomAuthApiClient } from "../core/network_client/custom_auth_api/ICu 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 { JitClient } from "../core/interaction_client/jit/JitClient.js"; import { NoCachedAccountFoundError } from "../core/error/NoCachedAccountFoundError.js"; import * as ArgumentValidator from "../core/utils/ArgumentValidator.js"; import { UserAlreadySignedInError } from "../core/error/UserAlreadySignedInError.js"; @@ -48,6 +51,7 @@ import { UnsupportedEnvironmentError } from "../core/error/UnsupportedEnvironmen 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 { AuthMethodRegistrationRequiredState } from "../core/auth_flow/jit/state/AuthMethodRegistrationState.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"; @@ -63,6 +67,7 @@ export class CustomAuthStandardController private readonly signInClient: SignInClient; private readonly signUpClient: SignUpClient; private readonly resetPasswordClient: ResetPasswordClient; + private readonly jitClient: JitClient; private readonly cacheClient: CustomAuthSilentCacheClient; private readonly customAuthConfig: CustomAuthBrowserConfiguration; private readonly authority: CustomAuthAuthority; @@ -123,6 +128,7 @@ export class CustomAuthStandardController this.signUpClient = interactionClientFactory.create(SignUpClient); this.resetPasswordClient = interactionClientFactory.create(ResetPasswordClient); + this.jitClient = interactionClientFactory.create(JitClient); this.cacheClient = interactionClientFactory.create( CustomAuthSilentCacheClient ); @@ -235,6 +241,7 @@ export class CustomAuthStandardController config: this.customAuthConfig, signInClient: this.signInClient, cacheClient: this.cacheClient, + jitClient: this.jitClient, username: signInInputs.username, codeLength: startResult.codeLength, scopes: signInInputs.scopes ?? [], @@ -264,6 +271,7 @@ export class CustomAuthStandardController config: this.customAuthConfig, signInClient: this.signInClient, cacheClient: this.cacheClient, + jitClient: this.jitClient, username: signInInputs.username, scopes: signInInputs.scopes ?? [], claims: signInInputs.claims, @@ -289,24 +297,61 @@ export class CustomAuthStandardController claims: signInInputs.claims, }; - const completedResult = await this.signInClient.submitPassword( - submitPasswordParams - ); + const submitPasswordResult = + 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 - ); + if ( + submitPasswordResult.type === SIGN_IN_COMPLETED_RESULT_TYPE + ) { + const accountInfo = new CustomAuthAccountData( + submitPasswordResult.authenticationResult.account, + this.customAuthConfig, + this.cacheClient, + this.logger, + correlationId + ); - return new SignInResult( - new SignInCompletedState(), - accountInfo - ); + return new SignInResult( + new SignInCompletedState(), + accountInfo + ); + } else if ( + submitPasswordResult.type === + SIGN_IN_JIT_REQUIRED_RESULT_TYPE + ) { + // Authentication method registration is required - create AuthMethodRegistrationRequiredState + this.logger.verbose( + "Authentication method registration required for sign-in.", + correlationId + ); + + return new SignInResult( + new AuthMethodRegistrationRequiredState({ + correlationId: correlationId, + continuationToken: + submitPasswordResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + jitClient: this.jitClient, + cacheClient: this.cacheClient, + authMethods: submitPasswordResult.authMethods, + username: signInInputs.username, + scopes: signInInputs.scopes ?? [], + claims: signInInputs.claims, + }) + ); + } else { + // Unexpected result type + const result = submitPasswordResult as { type: string }; + const error = new Error( + `Unexpected result type: ${result.type}` + ); + return SignInResult.createWithError(error); + } } this.logger.error( @@ -391,6 +436,7 @@ export class CustomAuthStandardController signInClient: this.signInClient, signUpClient: this.signUpClient, cacheClient: this.cacheClient, + jitClient: this.jitClient, username: signUpInputs.username, codeLength: startResult.codeLength, codeResendInterval: startResult.interval, @@ -414,6 +460,7 @@ export class CustomAuthStandardController signInClient: this.signInClient, signUpClient: this.signUpClient, cacheClient: this.cacheClient, + jitClient: this.jitClient, username: signUpInputs.username, }) ); @@ -483,6 +530,7 @@ export class CustomAuthStandardController signInClient: this.signInClient, resetPasswordClient: this.resetPasswordClient, cacheClient: this.cacheClient, + jitClient: this.jitClient, username: resetPasswordInputs.username, codeLength: startResult.codeLength, }) diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts index 20f1bd7a00..e27366f55a 100644 --- a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts @@ -139,6 +139,14 @@ export abstract class AuthFlowErrorBase { this.errorData.errorCodes?.includes(50142) === true ); } + + protected isInvalidAuthMethodRegistrationInputError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.INVALID_REQUEST && + this.errorData.errorCodes?.includes(901001) === true + ); + } } export abstract class AuthActionErrorBase extends AuthFlowErrorBase { diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts index 4edc3b934b..7b6522b91c 100644 --- a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts @@ -42,10 +42,24 @@ export abstract class AuthFlowResultBase< if (error instanceof CustomAuthError) { return error; } else if (error instanceof AuthError) { + const errorCodes: Array = []; + + if ("errorNo" in error) { + if (typeof error.errorNo === "string") { + const code = Number(error.errorNo); + if (!isNaN(code)) { + errorCodes.push(code); + } + } else if (typeof error.errorNo === "number") { + errorCodes.push(error.errorNo); + } + } + return new MsalCustomAuthError( error.errorCode, error.errorMessage, error.subError, + errorCodes, error.correlationId ); } else { diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts index 8224f1ce2c..28c0711785 100644 --- a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts @@ -31,7 +31,7 @@ export abstract class AuthFlowActionRequiredStateBase< * Creates a new instance of AuthFlowActionRequiredStateBase. * @param stateParameters The parameters for the auth state. */ - protected constructor(protected readonly stateParameters: TParameter) { + constructor(protected readonly stateParameters: TParameter) { ensureArgumentIsNotEmptyString( "correlationId", stateParameters.correlationId diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/AuthMethodDetails.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/AuthMethodDetails.ts new file mode 100644 index 0000000000..682c28626b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/AuthMethodDetails.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationMethod } from "../../network_client/custom_auth_api/types/ApiResponseTypes.js"; + +/** + * Details for an authentication method to be registered. + */ +export interface AuthMethodDetails { + /** + * The authentication method type to register. + */ + authMethodType: AuthenticationMethod; + + /** + * The verification contact (email, phone number) for the authentication method. + */ + verificationContact: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.ts new file mode 100644 index 0000000000..7bdbe29260 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthActionErrorBase } from "../../AuthFlowErrorBase.js"; + +/** + * Error that occurred during authentication method challenge request. + */ +export class AuthMethodRegistrationChallengeMethodError extends AuthActionErrorBase { + /** + * Checks if the input for auth method registration is incorrect. + * @returns true if the input is incorrect, false otherwise. + */ + isInvalidInput(): boolean { + return this.isInvalidAuthMethodRegistrationInputError(); + } +} + +/** + * Error that occurred during authentication method challenge submission. + */ +export class AuthMethodRegistrationSubmitChallengeError extends AuthActionErrorBase { + /** + * Checks if the submitted challenge code is incorrect. + * @returns true if the challenge code is incorrect, false otherwise. + */ + isIncorrectChallenge(): boolean { + return this.isInvalidCodeError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.ts new file mode 100644 index 0000000000..c3f85b2975 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../AuthFlowResultBase.js"; +import { AuthMethodRegistrationChallengeMethodError } from "../error_type/AuthMethodRegistrationError.js"; +import type { AuthMethodVerificationRequiredState } from "../state/AuthMethodRegistrationState.js"; +import { CustomAuthAccountData } from "../../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { AuthMethodRegistrationCompletedState } from "../state/AuthMethodRegistrationCompletedState.js"; +import { AuthMethodRegistrationFailedState } from "../state/AuthMethodRegistrationFailedState.js"; + +/** + * Result of challenging an authentication method for registration. + * Uses base state type to avoid circular dependencies. + */ +export class AuthMethodRegistrationChallengeMethodResult extends AuthFlowResultBase< + AuthMethodRegistrationChallengeMethodResultState, + AuthMethodRegistrationChallengeMethodError, + CustomAuthAccountData +> { + /** + * Creates an AuthMethodRegistrationChallengeMethodResult with an error. + * @param error The error that occurred. + * @returns The AuthMethodRegistrationChallengeMethodResult with error. + */ + static createWithError( + error: unknown + ): AuthMethodRegistrationChallengeMethodResult { + const result = new AuthMethodRegistrationChallengeMethodResult( + new AuthMethodRegistrationFailedState() + ); + result.error = new AuthMethodRegistrationChallengeMethodError( + AuthMethodRegistrationChallengeMethodResult.createErrorData(error) + ); + return result; + } + + /** + * Checks if the result indicates that verification is required. + * @returns true if verification is required, false otherwise. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + isVerificationRequired(): boolean { + return ( + this.state.constructor?.name === + "AuthMethodVerificationRequiredState" + ); + } + + /** + * Checks if the result indicates that registration is completed (fast-pass scenario). + * @returns true if registration is completed, false otherwise. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + isCompleted(): boolean { + return ( + this.state.constructor?.name === + "AuthMethodRegistrationCompletedState" + ); + } + + /** + * Checks if the result is in a failed state. + * @returns true if the result is failed, false otherwise. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + isFailed(): boolean { + return this.state instanceof AuthMethodRegistrationFailedState; + } +} + +/** + * Type definition for possible states in AuthMethodRegistrationChallengeMethodResult. + */ +export type AuthMethodRegistrationChallengeMethodResultState = + | AuthMethodVerificationRequiredState + | AuthMethodRegistrationCompletedState + | AuthMethodRegistrationFailedState; diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.ts new file mode 100644 index 0000000000..bd3787dedf --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../AuthFlowResultBase.js"; +import { AuthMethodRegistrationSubmitChallengeError } from "../error_type/AuthMethodRegistrationError.js"; +import { CustomAuthAccountData } from "../../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { AuthMethodRegistrationFailedState } from "../state/AuthMethodRegistrationFailedState.js"; +import { AuthMethodRegistrationCompletedState } from "../state/AuthMethodRegistrationCompletedState.js"; + +/** + * Result of submitting a challenge for authentication method registration. + */ +export class AuthMethodRegistrationSubmitChallengeResult extends AuthFlowResultBase< + AuthMethodRegistrationSubmitChallengeResultState, + AuthMethodRegistrationSubmitChallengeError, + CustomAuthAccountData +> { + /** + * Creates an AuthMethodRegistrationSubmitChallengeResult with an error. + * @param error The error that occurred. + * @returns The AuthMethodRegistrationSubmitChallengeResult with error. + */ + static createWithError( + error: unknown + ): AuthMethodRegistrationSubmitChallengeResult { + const result = new AuthMethodRegistrationSubmitChallengeResult( + new AuthMethodRegistrationFailedState() + ); + result.error = new AuthMethodRegistrationSubmitChallengeError( + AuthMethodRegistrationSubmitChallengeResult.createErrorData(error) + ); + return result; + } + + /** + * Checks if the result indicates that registration is completed. + * @returns true if registration is completed, false otherwise. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + isCompleted(): boolean { + return ( + this.state.constructor?.name === + "AuthMethodRegistrationCompletedState" + ); + } + + /** + * Checks if the result is in a failed state. + * @returns true if the result is failed, false otherwise. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + isFailed(): boolean { + return this.state instanceof AuthMethodRegistrationFailedState; + } +} + +/** + * Type definition for possible states in AuthMethodRegistrationSubmitChallengeResult. + */ +export type AuthMethodRegistrationSubmitChallengeResultState = + | AuthMethodRegistrationCompletedState + | AuthMethodRegistrationFailedState; diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationCompletedState.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationCompletedState.ts new file mode 100644 index 0000000000..8872d5597e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationCompletedState.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../AuthFlowState.js"; + +export class AuthMethodRegistrationCompletedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationFailedState.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationFailedState.ts new file mode 100644 index 0000000000..4aa8db4b6f --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationFailedState.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../AuthFlowState.js"; + +export class AuthMethodRegistrationFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.ts new file mode 100644 index 0000000000..68c47d25f2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.ts @@ -0,0 +1,260 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AuthMethodRegistrationStateParameters, + AuthMethodRegistrationRequiredStateParameters, + AuthMethodVerificationRequiredStateParameters, +} from "./AuthMethodRegistrationStateParameters.js"; +import { AuthMethodDetails } from "../AuthMethodDetails.js"; +import { + JitChallengeAuthMethodParams, + JitSubmitChallengeParams, +} from "../../../interaction_client/jit/parameter/JitParams.js"; +import { CustomAuthAccountData } from "../../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { + JIT_VERIFICATION_REQUIRED_RESULT_TYPE, + JIT_COMPLETED_RESULT_TYPE, +} from "../../../interaction_client/jit/result/JitActionResult.js"; +import { UnexpectedError } from "../../../error/UnexpectedError.js"; +import { AuthenticationMethod } from "../../../network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { AuthFlowActionRequiredStateBase } from "../../AuthFlowState.js"; +import { GrantType } from "../../../../CustomAuthConstants.js"; +import { AuthMethodRegistrationChallengeMethodResult } from "../result/AuthMethodRegistrationChallengeMethodResult.js"; +import { AuthMethodRegistrationSubmitChallengeResult } from "../result/AuthMethodRegistrationSubmitChallengeResult.js"; +import { AuthMethodRegistrationCompletedState } from "./AuthMethodRegistrationCompletedState.js"; + +/** + * Abstract base class for authentication method registration states. + */ +abstract class AuthMethodRegistrationState< + TParameters extends AuthMethodRegistrationStateParameters +> extends AuthFlowActionRequiredStateBase { + /** + * Internal method to challenge an authentication method. + * @param authMethodDetails The authentication method details to challenge. + * @returns Promise that resolves to AuthMethodRegistrationChallengeMethodResult. + */ + protected async challengeAuthMethodInternal( + authMethodDetails: AuthMethodDetails + ): Promise { + try { + this.stateParameters.logger.verbose( + `Challenging authentication method - '${authMethodDetails.authMethodType.id}' for auth method registration.`, + this.stateParameters.correlationId + ); + + const challengeParams: JitChallengeAuthMethodParams = { + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + authMethod: authMethodDetails.authMethodType, + verificationContact: authMethodDetails.verificationContact, + scopes: this.stateParameters.scopes ?? [], + username: this.stateParameters.username, + claims: this.stateParameters.claims, + }; + + const result = + await this.stateParameters.jitClient.challengeAuthMethod( + challengeParams + ); + + this.stateParameters.logger.verbose( + "Authentication method challenged successfully for auth method registration.", + this.stateParameters.correlationId + ); + + if (result.type === JIT_VERIFICATION_REQUIRED_RESULT_TYPE) { + // Verification required + this.stateParameters.logger.verbose( + "Auth method verification required.", + this.stateParameters.correlationId + ); + + return new AuthMethodRegistrationChallengeMethodResult( + new AuthMethodVerificationRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + config: this.stateParameters.config, + logger: this.stateParameters.logger, + jitClient: this.stateParameters.jitClient, + cacheClient: this.stateParameters.cacheClient, + challengeChannel: result.challengeChannel, + challengeTargetLabel: result.challengeTargetLabel, + codeLength: result.codeLength, + scopes: this.stateParameters.scopes ?? [], + username: this.stateParameters.username, + claims: this.stateParameters.claims, + }) + ); + } else if (result.type === JIT_COMPLETED_RESULT_TYPE) { + // Registration completed (fast-pass scenario) + this.stateParameters.logger.verbose( + "Auth method registration completed via fast-pass.", + this.stateParameters.correlationId + ); + + const accountInfo = new CustomAuthAccountData( + result.authenticationResult.account, + this.stateParameters.config, + this.stateParameters.cacheClient, + this.stateParameters.logger, + this.stateParameters.correlationId + ); + + return new AuthMethodRegistrationChallengeMethodResult( + new AuthMethodRegistrationCompletedState(), + accountInfo + ); + } else { + // Handle unexpected result type with proper typing + this.stateParameters.logger.error( + "Unexpected result type from auth challenge method", + this.stateParameters.correlationId + ); + throw new UnexpectedError( + "Unexpected result type from auth challenge method" + ); + } + } catch (error) { + this.stateParameters.logger.error( + "Failed to challenge authentication method for auth method registration.", + this.stateParameters.correlationId + ); + return AuthMethodRegistrationChallengeMethodResult.createWithError( + error + ); + } + } +} + +/** + * State indicating that authentication method registration is required. + */ +export class AuthMethodRegistrationRequiredState extends AuthMethodRegistrationState { + /** + * Gets the available authentication methods for registration. + * @returns Array of available authentication methods. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + getAuthMethods(): AuthenticationMethod[] { + return this.stateParameters.authMethods; + } + + /** + * Challenges an authentication method for registration. + * @param authMethodDetails The authentication method details to challenge. + * @returns Promise that resolves to AuthMethodRegistrationChallengeMethodResult. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + async challengeAuthMethod( + authMethodDetails: AuthMethodDetails + ): Promise { + return this.challengeAuthMethodInternal(authMethodDetails); + } +} + +/** + * State indicating that verification is required for the challenged authentication method. + */ +export class AuthMethodVerificationRequiredState extends AuthMethodRegistrationState { + /** + * Gets the length of the expected verification code. + * @returns The code length. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } + + /** + * Gets the channel through which the challenge was sent. + * @returns The challenge channel (e.g., "email"). + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + getChannel(): string { + return this.stateParameters.challengeChannel; + } + + /** + * Gets the target label indicating where the challenge was sent. + * @returns The challenge target label (e.g., masked email address). + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + getSentTo(): string { + return this.stateParameters.challengeTargetLabel; + } + + /** + * Submits the verification challenge to complete the authentication method registration. + * @param code The verification code entered by the user. + * @returns Promise that resolves to AuthMethodRegistrationSubmitChallengeResult. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + async submitChallenge( + code: string + ): Promise { + try { + this.ensureCodeIsValid(code, this.getCodeLength()); + + this.stateParameters.logger.verbose( + "Submitting auth method challenge.", + this.stateParameters.correlationId + ); + + const submitParams: JitSubmitChallengeParams = { + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + scopes: this.stateParameters.scopes ?? [], + grantType: GrantType.OOB, + challenge: code, + username: this.stateParameters.username, + claims: this.stateParameters.claims, + }; + + const result = await this.stateParameters.jitClient.submitChallenge( + submitParams + ); + + this.stateParameters.logger.verbose( + "Auth method challenge submitted successfully.", + this.stateParameters.correlationId + ); + + const accountInfo = new CustomAuthAccountData( + result.authenticationResult.account, + this.stateParameters.config, + this.stateParameters.cacheClient, + this.stateParameters.logger, + this.stateParameters.correlationId + ); + + return new AuthMethodRegistrationSubmitChallengeResult( + new AuthMethodRegistrationCompletedState(), + accountInfo + ); + } catch (error) { + this.stateParameters.logger.error( + "Failed to submit auth method challenge.", + this.stateParameters.correlationId + ); + return AuthMethodRegistrationSubmitChallengeResult.createWithError( + error + ); + } + } + + /** + * Challenges a different authentication method for registration. + * @param authMethodDetails The authentication method details to challenge. + * @returns Promise that resolves to AuthMethodRegistrationChallengeMethodResult. + * @warning This API is experimental. It may be changed in the future without notice. Do not use in production applications. + */ + async challengeAuthMethod( + authMethodDetails: AuthMethodDetails + ): Promise { + return this.challengeAuthMethodInternal(authMethodDetails); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationStateParameters.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationStateParameters.ts new file mode 100644 index 0000000000..835f633ad2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationStateParameters.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateParameters } from "../../AuthFlowState.js"; +import { JitClient } from "../../../interaction_client/jit/JitClient.js"; +import { AuthenticationMethod } from "../../../network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { CustomAuthSilentCacheClient } from "../../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +export interface AuthMethodRegistrationStateParameters + extends AuthFlowActionRequiredStateParameters { + jitClient: JitClient; + cacheClient: CustomAuthSilentCacheClient; + scopes?: string[]; + username?: string; + claims?: string; +} + +export interface AuthMethodRegistrationRequiredStateParameters + extends AuthMethodRegistrationStateParameters { + authMethods: AuthenticationMethod[]; +} + +export interface AuthMethodVerificationRequiredStateParameters + extends AuthMethodRegistrationStateParameters { + challengeChannel: string; + challengeTargetLabel: string; + codeLength: number; +} diff --git a/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts b/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts index d9bcc04104..c6f14c21c0 100644 --- a/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts +++ b/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts @@ -6,17 +6,14 @@ import { CustomAuthError } from "./CustomAuthError.js"; export class MsalCustomAuthError extends CustomAuthError { - subError: string | undefined; - constructor( error: string, errorDescription?: string, subError?: string, + errorCodes?: Array, correlationId?: string ) { - super(error, errorDescription, correlationId); + super(error, errorDescription, correlationId, errorCodes, subError); Object.setPrototypeOf(this, MsalCustomAuthError.prototype); - - this.subError = subError || ""; } } diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts index 8e06b08256..1173d9369f 100644 --- a/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts @@ -15,6 +15,7 @@ import { ICrypto, IPerformanceClient, Logger, + ResponseHandler, } from "@azure/msal-common/browser"; import { EventHandler } from "../../../event/EventHandler.js"; import { INavigationClient } from "../../../navigation/INavigationClient.js"; @@ -24,8 +25,11 @@ import { SsoSilentRequest } from "../../../request/SsoSilentRequest.js"; import { EndSessionRequest } from "../../../request/EndSessionRequest.js"; import { ClearCacheRequest } from "../../../request/ClearCacheRequest.js"; import { AuthenticationResult } from "../../../response/AuthenticationResult.js"; +import { SignInTokenResponse } from "../network_client/custom_auth_api/types/ApiResponseTypes.js"; export abstract class CustomAuthInteractionClientBase extends StandardInteractionClient { + private readonly tokenResponseHandler: ResponseHandler; + constructor( config: BrowserConfiguration, storageImpl: BrowserCacheManager, @@ -46,6 +50,15 @@ export abstract class CustomAuthInteractionClientBase extends StandardInteractio navigationClient, performanceClient ); + + this.tokenResponseHandler = new ResponseHandler( + this.config.auth.clientId, + this.browserStorage, + this.browserCrypto, + this.logger, + null, + null + ); } protected getChallengeTypes( @@ -74,6 +87,39 @@ export abstract class CustomAuthInteractionClientBase extends StandardInteractio ]; } + /** + * Common method to handle token response processing. + * @param tokenResponse The token response from the API + * @param requestScopes Scopes for the token request + * @param correlationId Correlation ID for logging + * @returns Authentication result from the token response + */ + protected async handleTokenResponse( + tokenResponse: SignInTokenResponse, + requestScopes: string[], + correlationId: string + ): Promise { + this.logger.verbose("Processing token response.", correlationId); + + const requestTimestamp = Math.round(new Date().getTime() / 1000.0); + + // Save tokens and create authentication result 44s + const result = + await this.tokenResponseHandler.handleServerTokenResponse( + tokenResponse, + this.customAuthAuthority, + requestTimestamp, + { + authority: this.customAuthAuthority.canonicalAuthority, + correlationId: + tokenResponse.correlation_id ?? correlationId, + scopes: requestScopes, + } + ); + + return result as AuthenticationResult; + } + // It is not necessary to implement this method from base class. acquireToken( // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/jit/JitClient.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/JitClient.ts new file mode 100644 index 0000000000..0494d0d527 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/JitClient.ts @@ -0,0 +1,206 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthInteractionClientBase } from "../CustomAuthInteractionClientBase.js"; +import { + JitGetAuthMethodsParams, + JitChallengeAuthMethodParams, + JitSubmitChallengeParams, +} from "./parameter/JitParams.js"; +import { + JitGetAuthMethodsResult, + JitVerificationRequiredResult, + JitCompletedResult, + createJitGetAuthMethodsResult, + createJitVerificationRequiredResult, + createJitCompletedResult, +} from "./result/JitActionResult.js"; +import { + DefaultCustomAuthApiCodeLength, + ChallengeType, + GrantType, +} from "../../../CustomAuthConstants.js"; +import * as PublicApiId from "../../telemetry/PublicApiId.js"; +import { + RegisterIntrospectRequest, + RegisterChallengeRequest, + RegisterContinueRequest, + SignInContinuationTokenRequest, +} from "../../network_client/custom_auth_api/types/ApiRequestTypes.js"; + +/** + * JIT client for handling just-in-time authentication method registration flows. + */ +export class JitClient extends CustomAuthInteractionClientBase { + /** + * Gets available authentication methods for JIT registration. + * @param parameters The parameters for getting auth methods. + * @returns Promise that resolves to JitGetAuthMethodsResult. + */ + async getAuthMethods( + parameters: JitGetAuthMethodsParams + ): Promise { + const apiId = PublicApiId.JIT_GET_AUTH_METHODS; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + this.logger.verbose( + "Calling introspect endpoint for getting auth methods.", + parameters.correlationId + ); + + const request: RegisterIntrospectRequest = { + continuation_token: parameters.continuationToken, + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + const introspectResponse = + await this.customAuthApiClient.registerApi.introspect(request); + + this.logger.verbose( + "Introspect endpoint called for getting auth methods.", + parameters.correlationId + ); + + return createJitGetAuthMethodsResult({ + correlationId: introspectResponse.correlation_id, + continuationToken: introspectResponse.continuation_token, + authMethods: introspectResponse.methods, + }); + } + + /** + * Challenges an authentication method for JIT registration. + * @param parameters The parameters for challenging the auth method. + * @returns Promise that resolves to either JitVerificationRequiredResult or JitCompletedResult. + */ + async challengeAuthMethod( + parameters: JitChallengeAuthMethodParams + ): Promise { + const apiId = PublicApiId.JIT_CHALLENGE_AUTH_METHOD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + this.logger.verbose( + "Calling challenge endpoint for getting auth method.", + parameters.correlationId + ); + + const challengeReq: RegisterChallengeRequest = { + continuation_token: parameters.continuationToken, + challenge_type: parameters.authMethod.challenge_type, + challenge_target: parameters.verificationContact, + challenge_channel: parameters.authMethod.challenge_channel, + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + const challengeResponse = + await this.customAuthApiClient.registerApi.challenge(challengeReq); + + this.logger.verbose( + "Challenge endpoint called for auth method registration.", + parameters.correlationId + ); + + /* + * Handle fast-pass scenario (preverified) + * This occurs when the user selects the same email used during sign-up + * Since the email was already verified during sign-up, no additional verification is needed + */ + if (challengeResponse.challenge_type === ChallengeType.PREVERIFIED) { + this.logger.verbose( + "Fast-pass scenario detected - completing registration without additional verification.", + challengeResponse.correlation_id + ); + + // Use submitChallenge for fast-pass scenario with continuation_token grant type + const fastPassParams: JitSubmitChallengeParams = { + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token, + grantType: GrantType.CONTINUATION_TOKEN, + scopes: parameters.scopes, + username: parameters.username, + claims: parameters.claims, + }; + + const completedResult = await this.submitChallenge(fastPassParams); + return completedResult; + } + + // Verification required + return createJitVerificationRequiredResult({ + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token, + challengeChannel: challengeResponse.challenge_channel, + challengeTargetLabel: challengeResponse.challenge_target, + codeLength: + challengeResponse.code_length || DefaultCustomAuthApiCodeLength, + }); + } + + /** + * Submits challenge response and completes JIT registration. + * @param parameters The parameters for submitting the challenge. + * @returns Promise that resolves to JitCompletedResult. + */ + async submitChallenge( + parameters: JitSubmitChallengeParams + ): Promise { + const apiId = PublicApiId.JIT_SUBMIT_CHALLENGE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + this.logger.verbose( + "Calling continue endpoint for auth method challenge submission.", + parameters.correlationId + ); + + // Submit challenge to complete registration + const continueReq: RegisterContinueRequest = { + continuation_token: parameters.continuationToken, + grant_type: parameters.grantType, + ...(parameters.challenge && { + oob: parameters.challenge, + }), + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + const continueResponse = + await this.customAuthApiClient.registerApi.continue(continueReq); + + this.logger.verbose( + "Continue endpoint called for auth method challenge submission.", + parameters.correlationId + ); + + // Use continuation token to get authentication tokens + const scopes = this.getScopes(parameters.scopes); + const tokenRequest: SignInContinuationTokenRequest = { + continuation_token: continueResponse.continuation_token, + scope: scopes.join(" "), + correlationId: continueResponse.correlation_id, + telemetryManager: telemetryManager, + ...(parameters.claims && { + claims: parameters.claims, + }), + }; + + const tokenResponse = + await this.customAuthApiClient.signInApi.requestTokenWithContinuationToken( + tokenRequest + ); + + const authResult = await this.handleTokenResponse( + tokenResponse, + scopes, + continueResponse.correlation_id + ); + + return createJitCompletedResult({ + correlationId: continueResponse.correlation_id, + authenticationResult: authResult, + }); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/jit/parameter/JitParams.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/parameter/JitParams.ts new file mode 100644 index 0000000000..c98ac925df --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/parameter/JitParams.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationMethod } from "../../../network_client/custom_auth_api/types/ApiResponseTypes.js"; + +export interface JitClientParametersBase { + correlationId: string; + continuationToken: string; +} + +export type JitGetAuthMethodsParams = JitClientParametersBase; + +export interface JitChallengeAuthMethodParams extends JitClientParametersBase { + authMethod: AuthenticationMethod; + verificationContact: string; + scopes: string[]; + username?: string; + claims?: string; +} + +export interface JitSubmitChallengeParams extends JitClientParametersBase { + grantType: string; + challenge?: string; + scopes: string[]; + username?: string; + claims?: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/jit/result/JitActionResult.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/result/JitActionResult.ts new file mode 100644 index 0000000000..ac5b0a1c31 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/jit/result/JitActionResult.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationResult } from "../../../../../response/AuthenticationResult.js"; +import { AuthenticationMethod } from "../../../network_client/custom_auth_api/types/ApiResponseTypes.js"; + +interface JitActionResult { + type: string; + correlationId: string; +} + +interface JitContinuationTokenResult extends JitActionResult { + continuationToken: string; +} + +export interface JitGetAuthMethodsResult extends JitContinuationTokenResult { + type: typeof JIT_GET_AUTH_METHODS_RESULT_TYPE; + authMethods: AuthenticationMethod[]; +} + +export interface JitVerificationRequiredResult + extends JitContinuationTokenResult { + type: typeof JIT_VERIFICATION_REQUIRED_RESULT_TYPE; + challengeChannel: string; + challengeTargetLabel: string; + codeLength: number; +} + +export interface JitCompletedResult extends JitActionResult { + type: typeof JIT_COMPLETED_RESULT_TYPE; + authenticationResult: AuthenticationResult; +} + +// Result type constants +export const JIT_GET_AUTH_METHODS_RESULT_TYPE = "JitGetAuthMethodsResult"; +export const JIT_VERIFICATION_REQUIRED_RESULT_TYPE = + "JitVerificationRequiredResult"; +export const JIT_COMPLETED_RESULT_TYPE = "JitCompletedResult"; + +export function createJitGetAuthMethodsResult( + input: Omit +): JitGetAuthMethodsResult { + return { + type: JIT_GET_AUTH_METHODS_RESULT_TYPE, + ...input, + }; +} + +export function createJitVerificationRequiredResult( + input: Omit +): JitVerificationRequiredResult { + return { + type: JIT_VERIFICATION_REQUIRED_RESULT_TYPE, + ...input, + }; +} + +export function createJitCompletedResult( + input: Omit +): JitCompletedResult { + return { + type: JIT_COMPLETED_RESULT_TYPE, + ...input, + }; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts index cbc2725a6e..7a81dd33a7 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts @@ -6,6 +6,7 @@ import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; import { SignupApiClient } from "./SignupApiClient.js"; import { SignInApiClient } from "./SignInApiClient.js"; +import { RegisterApiClient } from "./RegisterApiClient.js"; import { ICustomAuthApiClient } from "./ICustomAuthApiClient.js"; import { IHttpClient } from "../http_client/IHttpClient.js"; @@ -13,6 +14,7 @@ export class CustomAuthApiClient implements ICustomAuthApiClient { signInApi: SignInApiClient; signUpApi: SignupApiClient; resetPasswordApi: ResetPasswordApiClient; + registerApi: RegisterApiClient; constructor( customAuthApiBaseUrl: string, @@ -42,5 +44,10 @@ export class CustomAuthApiClient implements ICustomAuthApiClient { capabilities, customAuthApiQueryParams ); + this.registerApi = new RegisterApiClient( + customAuthApiBaseUrl, + clientId, + httpClient + ); } } diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts index 4b98d345d4..ea473d4f36 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts @@ -16,3 +16,7 @@ export const RESET_PWD_CHALLENGE = "/resetpassword/v1.0/challenge"; export const RESET_PWD_CONTINUE = "/resetpassword/v1.0/continue"; export const RESET_PWD_SUBMIT = "/resetpassword/v1.0/submit"; export const RESET_PWD_POLL = "/resetpassword/v1.0/poll_completion"; + +export const REGISTER_INTROSPECT = "/register/v1.0/introspect"; +export const REGISTER_CHALLENGE = "/register/v1.0/challenge"; +export const REGISTER_CONTINUE = "/register/v1.0/continue"; diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts index 6d4cad1186..c94201eded 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts @@ -6,8 +6,10 @@ import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; import { SignupApiClient } from "./SignupApiClient.js"; import { SignInApiClient } from "./SignInApiClient.js"; +import { RegisterApiClient } from "./RegisterApiClient.js"; export interface ICustomAuthApiClient { signInApi: SignInApiClient; signUpApi: SignupApiClient; resetPasswordApi: ResetPasswordApiClient; + registerApi: RegisterApiClient; } diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.ts new file mode 100644 index 0000000000..d45c407f73 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { BaseApiClient } from "./BaseApiClient.js"; +import * as CustomAuthApiEndpoint from "./CustomAuthApiEndpoint.js"; +import { + RegisterIntrospectRequest, + RegisterChallengeRequest, + RegisterContinueRequest, +} from "./types/ApiRequestTypes.js"; +import { + RegisterIntrospectResponse, + RegisterChallengeResponse, + RegisterContinueResponse, +} from "./types/ApiResponseTypes.js"; + +export class RegisterApiClient extends BaseApiClient { + /** + * Gets available authentication methods for registration + */ + async introspect( + params: RegisterIntrospectRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.REGISTER_INTROSPECT, + { + continuation_token: params.continuation_token, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Sends challenge to specified authentication method + */ + async challenge( + params: RegisterChallengeRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.REGISTER_CHALLENGE, + { + continuation_token: params.continuation_token, + challenge_type: params.challenge_type, + challenge_target: params.challenge_target, + ...(params.challenge_channel && { + challenge_channel: params.challenge_channel, + }), + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Submits challenge response and continues registration + */ + async continue( + params: RegisterContinueRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.REGISTER_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: params.grant_type, + ...(params.oob && { oob: params.oob }), + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts index 4be39f09fc..342c75805b 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts @@ -84,6 +84,7 @@ export class SignInApiClient extends BaseApiClient { { continuation_token: params.continuation_token, challenge_type: params.challenge_type, + ...(params.id && { id: params.id }), }, params.telemetryManager, params.correlationId @@ -141,11 +142,11 @@ export class SignInApiClient extends BaseApiClient { return this.requestTokens( { continuation_token: params.continuation_token, - username: params.username, scope: params.scope, grant_type: GrantType.CONTINUATION_TOKEN, client_info: true, ...(params.claims && { claims: params.claims }), + ...(params.username && { username: params.username }), }, params.telemetryManager, params.correlationId diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts index 8c49a9d1d1..0083e7d7c2 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts @@ -14,6 +14,7 @@ export interface SignInInitiateRequest extends ApiRequestBase { export interface SignInChallengeRequest extends ApiRequestBase { challenge_type: string; continuation_token: string; + id?: string; } interface SignInTokenRequestBase extends ApiRequestBase { @@ -31,7 +32,7 @@ export interface SignInOobTokenRequest extends SignInTokenRequestBase { } export interface SignInContinuationTokenRequest extends SignInTokenRequestBase { - username: string; + username?: string; } /* Sign-up API request types */ @@ -90,3 +91,21 @@ export interface ResetPasswordSubmitRequest extends ApiRequestBase { export interface ResetPasswordPollCompletionRequest extends ApiRequestBase { continuation_token: string; } + +/* Register API request types */ +export interface RegisterIntrospectRequest extends ApiRequestBase { + continuation_token: string; +} + +export interface RegisterChallengeRequest extends ApiRequestBase { + continuation_token: string; + challenge_type: string; + challenge_target: string; + challenge_channel?: string; +} + +export interface RegisterContinueRequest extends ApiRequestBase { + continuation_token: string; + grant_type: string; + oob?: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts index 2910f01063..2086da9749 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts @@ -38,6 +38,13 @@ export interface SignInTokenResponse extends ApiResponseBase { ext_expires_in?: number; } +export interface AuthenticationMethod { + id: string; + challenge_type: string; + challenge_channel: string; + login_hint: string; +} + /* Sign-up API response types */ export type SignUpStartResponse = InitiateResponse; @@ -64,3 +71,23 @@ export interface ResetPasswordPollCompletionResponse extends ContinuousResponse { status: string; } + +/* Register API response types */ +export interface RegisterIntrospectResponse extends ApiResponseBase { + continuation_token: string; + methods: AuthenticationMethod[]; +} + +export interface RegisterChallengeResponse extends ApiResponseBase { + continuation_token: string; + challenge_type: string; + binding_method: string; + challenge_target: string; + challenge_channel: string; + code_length?: number; + interval?: number; +} + +export interface RegisterContinueResponse extends ApiResponseBase { + continuation_token: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts index a233a9aa4d..c70ef70aac 100644 --- a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts @@ -12,3 +12,4 @@ export const PASSWORD_IS_INVALID = "password_is_invalid"; export const INVALID_OOB_VALUE = "invalid_oob_value"; export const ATTRIBUTE_VALIATION_FAILED = "attribute_validation_failed"; export const NATIVEAUTHAPI_DISABLED = "nativeauthapi_disabled"; +export const REGISTRATION_REQUIRED = "registration_required"; diff --git a/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts b/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts index 487cf49bbc..ded060c12e 100644 --- a/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts +++ b/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts @@ -35,3 +35,8 @@ export const PASSWORD_RESET_RESEND_CODE = 100044; export const ACCOUNT_GET_ACCOUNT = 100061; export const ACCOUNT_SIGN_OUT = 100062; export const ACCOUNT_GET_ACCESS_TOKEN = 100063; + +// JIT (Just-In-Time) Auth Method Registration +export const JIT_GET_AUTH_METHODS = 100081; +export const JIT_CHALLENGE_AUTH_METHOD = 100082; +export const JIT_SUBMIT_CHALLENGE = 100083; diff --git a/lib/msal-browser/src/custom_auth/index.ts b/lib/msal-browser/src/custom_auth/index.ts index 6cf025cf81..15d0c08712 100644 --- a/lib/msal-browser/src/custom_auth/index.ts +++ b/lib/msal-browser/src/custom_auth/index.ts @@ -20,8 +20,9 @@ export { ICustomAuthPublicClientApplication } from "./ICustomAuthPublicClientApp // Configuration export { CustomAuthConfiguration } from "./configuration/CustomAuthConfiguration.js"; -// Account Data +// Models export { CustomAuthAccountData } from "./get_account/auth_flow/CustomAuthAccountData.js"; +export { AuthenticationMethod } from "./core/network_client/custom_auth_api/types/ApiResponseTypes.js"; // Operation Inputs export { @@ -50,13 +51,18 @@ export { SignInResult, SignInResultState, } from "./sign_in/auth_flow/result/SignInResult.js"; -export { SignInSubmitCodeResult } from "./sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +export { + SignInSubmitCodeResult, + SignInSubmitCodeResultState, +} 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"; +export { + SignInSubmitPasswordResult, + SignInSubmitPasswordResultState, +} from "./sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; // Sign-in Errors export { @@ -181,5 +187,30 @@ export { UnsupportedEnvironmentError } from "./core/error/UnsupportedEnvironment export { UserAccountAttributeError } from "./core/error/UserAccountAttributeError.js"; export { UserAlreadySignedInError } from "./core/error/UserAlreadySignedInError.js"; +// Auth Method Registration State +export { AuthMethodRegistrationRequiredState } from "./core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +export { AuthMethodVerificationRequiredState } from "./core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +export { AuthMethodRegistrationCompletedState } from "./core/auth_flow/jit/state/AuthMethodRegistrationCompletedState.js"; +export { AuthMethodRegistrationFailedState } from "./core/auth_flow/jit/state/AuthMethodRegistrationFailedState.js"; + +// Auth Method Registration Results +export { + AuthMethodRegistrationChallengeMethodResult, + AuthMethodRegistrationChallengeMethodResultState, +} from "./core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; +export { + AuthMethodRegistrationSubmitChallengeResult, + AuthMethodRegistrationSubmitChallengeResultState, +} from "./core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; + +// Auth Method Registration Errors +export { + AuthMethodRegistrationChallengeMethodError, + AuthMethodRegistrationSubmitChallengeError, +} from "./core/auth_flow/jit/error_type/AuthMethodRegistrationError.js"; + +// Auth Method Registration Types +export { AuthMethodDetails } from "./core/auth_flow/jit/AuthMethodDetails.js"; + // Components from msal_browser export { LogLevel } from "@azure/msal-common/browser"; diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts index fb2d9ff27c..519267880f 100644 --- a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts @@ -55,6 +55,7 @@ export class ResetPasswordCodeRequiredState extends ResetPasswordState { +export class SignInSubmitCodeResult extends AuthFlowResultBase< + SignInSubmitCodeResultState, + SignInSubmitCodeError, + CustomAuthAccountData +> { /** * Creates a new instance of SignInSubmitCodeResult with error data. * @param error The error that occurred. @@ -42,3 +47,13 @@ export class SignInSubmitCodeResult extends SignInSubmitCredentialResult extends AuthFlowResultBase< - SignInSubmitCredentialResultState, - TError, - CustomAuthAccountData -> { - /** - * Creates a new instance of SignInSubmitCredentialResult. - * @param state The state of the result. - * @param resultData The result data. - */ - constructor( - state: SignInSubmitCredentialResultState, - resultData?: CustomAuthAccountData - ) { - super(state, resultData); - } -} - -/** - * The possible states of the SignInSubmitCredentialResult. - * This includes: - * - SignInCompletedState: The sign-in process has completed successfully. - * - SignInFailedState: The sign-in process has failed. - */ -export type SignInSubmitCredentialResultState = - | SignInCompletedState - | SignInFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts index b653697aa0..4050867641 100644 --- a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts @@ -6,12 +6,18 @@ import { SignInSubmitPasswordError } from "../error_type/SignInError.js"; import { SignInCompletedState } from "../state/SignInCompletedState.js"; import { SignInFailedState } from "../state/SignInFailedState.js"; -import { SignInSubmitCredentialResult } from "./SignInSubmitCredentialResult.js"; +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { AuthMethodRegistrationRequiredState } from "../../../core/auth_flow/jit/state/AuthMethodRegistrationState.js"; /* * Result of a sign-in submit password operation. */ -export class SignInSubmitPasswordResult extends SignInSubmitCredentialResult { +export class SignInSubmitPasswordResult extends AuthFlowResultBase< + SignInSubmitPasswordResultState, + SignInSubmitPasswordError, + CustomAuthAccountData +> { static createWithError(error: unknown): SignInSubmitPasswordResult { const result = new SignInSubmitPasswordResult(new SignInFailedState()); result.error = new SignInSubmitPasswordError( @@ -38,4 +44,26 @@ export class SignInSubmitPasswordResult extends SignInSubmitCredentialResult this.customAuthApiClient.signInApi.requestTokensWithOob( request ), - scopes + scopes, + parameters.correlationId, + telemetryManager, + false // Don't handle JIT for code submission ); + + return result as Promise; } /** @@ -216,7 +178,7 @@ export class SignInClient extends CustomAuthInteractionClientBase { */ async submitPassword( parameters: SignInSubmitPasswordParams - ): Promise { + ): Promise { ensureArgumentIsNotEmptyString( "parameters.password", parameters.password, @@ -243,7 +205,10 @@ export class SignInClient extends CustomAuthInteractionClientBase { this.customAuthApiClient.signInApi.requestTokensWithPassword( request ), - scopes + scopes, + parameters.correlationId, + telemetryManager, + true // Handle JIT for password submission ); } @@ -254,7 +219,7 @@ export class SignInClient extends CustomAuthInteractionClientBase { */ async signInWithContinuationToken( parameters: SignInContinuationTokenParams - ): Promise { + ): Promise { const apiId = this.getPublicApiIdBySignInScenario( parameters.signInScenario, parameters.correlationId @@ -280,49 +245,86 @@ export class SignInClient extends CustomAuthInteractionClientBase { this.customAuthApiClient.signInApi.requestTokenWithContinuationToken( request ), - scopes - ); + scopes, + parameters.correlationId, + telemetryManager, + true // Handle JIT for continuation token sign-in (e.g., after sign-up or SSPR) + ) as Promise; } + /** + * Common method to handle token endpoint calls and create sign-in results. + * @param tokenEndpointCaller Function that calls the specific token endpoint + * @param scopes Scopes for the token request + * @param correlationId Correlation ID for logging and result + * @param handleJit Whether to handle JIT required errors or throw them + * @returns SignInCompletedResult with authentication result + */ private async performTokenRequest( tokenEndpointCaller: () => Promise, - requestScopes: string[] - ): Promise { + scopes: string[], + correlationId: string, + telemetryManager: ServerTelemetryManager, + handleJit: boolean + ): Promise { this.logger.verbose( "Calling token endpoint for sign in.", - this.correlationId + correlationId ); - const requestTimestamp = Math.round(new Date().getTime() / 1000.0); - const tokenResponse = await tokenEndpointCaller(); + try { + const tokenResponse = await tokenEndpointCaller(); - this.logger.verbose( - "Token endpoint called for sign in.", - this.correlationId - ); + this.logger.verbose( + "Token endpoint response received for sign in.", + correlationId + ); - // Save tokens and create authentication result. - const result = - await this.tokenResponseHandler.handleServerTokenResponse( + const authResult = await this.handleTokenResponse( tokenResponse, - this.customAuthAuthority, - requestTimestamp, - { - authority: this.customAuthAuthority.canonicalAuthority, - correlationId: tokenResponse.correlation_id ?? "", - scopes: requestScopes, - storeInCache: { - idToken: true, - accessToken: true, - refreshToken: true, - }, - } + scopes, + correlationId ); - return createSignInCompleteResult({ - correlationId: tokenResponse.correlation_id ?? "", - authenticationResult: result as AuthenticationResult, - }); + return createSignInCompleteResult({ + correlationId: tokenResponse.correlation_id ?? correlationId, + authenticationResult: authResult, + }); + } catch (error) { + if ( + handleJit && + error instanceof CustomAuthApiError && + error.subError === REGISTRATION_REQUIRED + ) { + this.logger.verbose( + "Auth method registration required for sign in.", + correlationId + ); + + // Call register introspect endpoint to get available authentication methods + const introspectRequest: RegisterIntrospectRequest = { + continuation_token: error.continuationToken ?? "", + correlationId: error.correlationId ?? correlationId, + telemetryManager, + }; + + const introspectResponse = + await this.customAuthApiClient.registerApi.introspect( + introspectRequest + ); + + return createSignInJitRequiredResult({ + correlationId: + introspectResponse.correlation_id ?? correlationId, + continuationToken: + introspectResponse.continuation_token ?? "", + authMethods: introspectResponse.methods, + }); + } + + // Re-throw any other errors or JIT errors when handleJit is false + throw error; + } } private async performChallengeRequest( diff --git a/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts index 0446f6e85c..70734e081c 100644 --- a/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts +++ b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts @@ -4,6 +4,7 @@ */ import { AuthenticationResult } from "../../../../response/AuthenticationResult.js"; +import { AuthenticationMethod } from "../../../core/network_client/custom_auth_api/types/ApiResponseTypes.js"; interface SignInActionResult { type: string; @@ -32,10 +33,16 @@ export interface SignInCodeSendResult extends SignInContinuationTokenResult { bindingMethod: string; } +export interface SignInJitRequiredResult extends SignInContinuationTokenResult { + type: typeof SIGN_IN_JIT_REQUIRED_RESULT_TYPE; + authMethods: AuthenticationMethod[]; +} + export const SIGN_IN_CODE_SEND_RESULT_TYPE = "SignInCodeSendResult"; export const SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE = "SignInPasswordRequiredResult"; export const SIGN_IN_COMPLETED_RESULT_TYPE = "SignInCompletedResult"; +export const SIGN_IN_JIT_REQUIRED_RESULT_TYPE = "SignInJitRequiredResult"; export function createSignInCompleteResult( input: Omit @@ -63,3 +70,12 @@ export function createSignInCodeSendResult( ...input, }; } + +export function createSignInJitRequiredResult( + input: Omit +): SignInJitRequiredResult { + return { + type: SIGN_IN_JIT_REQUIRED_RESULT_TYPE, + ...input, + }; +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts index a084cf6c0e..a4e61bdc47 100644 --- a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts @@ -83,6 +83,7 @@ export class SignUpAttributesRequiredState extends SignUpState ({ signInApi: signInApiClient, signUpApi: signUpApiClient, resetPasswordApi: resetPasswordApiClient, + registerApi: registerApiClient, })); return { @@ -59,16 +66,21 @@ jest.mock( signInApiClient, signUpApiClient, resetPasswordApiClient, + registerApiClient, }; } ); describe("CustomAuthStandardController", () => { let controller: CustomAuthStandardController; - const { signInApiClient, signUpApiClient, resetPasswordApiClient } = - jest.requireMock( - "../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" - ); + const { + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + registerApiClient, + } = jest.requireMock( + "../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); beforeEach(() => { const context = new CustomAuthOperatingContext(customAuthConfig); @@ -199,6 +211,228 @@ describe("CustomAuthStandardController", () => { expect(result.error?.isRedirectRequired()).toEqual(true); expect(result.isFailed()).toBe(true); }); + + it("should handle invalid client configuration error", async () => { + const configError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid client configuration", + "correlation-id" + ); + signInApiClient.initiate.mockRejectedValue(configError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle null signInInputs gracefully", async () => { + const result = await controller.signIn(null as any); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle undefined signInInputs gracefully", async () => { + const result = await controller.signIn(undefined as any); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle whitespace-only username", async () => { + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: " ", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with custom scopes", async () => { + signInApiClient.initiate.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_1", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + scopes: ["custom.scope1", "custom.scope2", "custom.scope3"], + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + // Verify scopes are passed through + expect(result.state?.constructor.name).toBe( + "SignInPasswordRequiredState" + ); + }); + + it("should handle sign-in when user is already signed in", async () => { + // Mock that a user is already cached + jest.spyOn(controller, "getCurrentAccount").mockReturnValue({ + data: {} as any, + error: undefined, + } as any); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with unsupported challenge type", async () => { + // Setup the API to throw an error for unsupported challenge type + const unsupportedError = new Error("Unsupported challenge type"); + unsupportedError.name = "CustomAuthApiError"; + signInApiClient.initiate.mockRejectedValue(unsupportedError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with API network error", async () => { + // Setup the API to throw a network error + const networkError = new Error("Network error during sign-in"); + networkError.name = "NetworkError"; + signInApiClient.initiate.mockRejectedValue(networkError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with JIT required after password submission", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + // Mock JIT required error - the API should throw, not return + const jitError = new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "JIT authentication method registration required", + "corr123", + [50011], // JIT_REQUIRED suberror code + undefined, + undefined, + "jit_continuation_token" + ); + jitError.subError = CustomAuthApiSuberror.REGISTRATION_REQUIRED; // Set the REGISTRATION_REQUIRED suberror + signInApiClient.requestTokensWithPassword.mockRejectedValue( + jitError + ); + + // Mock the register introspect call that will be made by SignInClient + registerApiClient.introspect.mockResolvedValue({ + correlation_id: "corr123", + continuation_token: "introspect_continuation_token", + methods: [ + { + id: "email_method", + challenge_type: "email", + challenge_channel: "email", + login_hint: "test@test.com", + }, + { + id: "sms_method", + challenge_type: "sms", + challenge_channel: "phone_number", + login_hint: "+1234567890", + }, + ], + }); + + 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.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.state?.constructor.name).toBe( + "AuthMethodRegistrationRequiredState" + ); + }); + + it("should handle unexpected result type after password submission", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + // Mock an unknown result type + signInApiClient.requestTokensWithPassword.mockResolvedValue({ + type: "unknown_result_type", + correlation_id: "corr123", + continuation_token: "continuation_token_3", + }); + + 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).toBeInstanceOf(SignInError); + }); }); describe("signUp", () => { @@ -326,6 +560,182 @@ describe("CustomAuthStandardController", () => { expect(result.error?.isInvalidPassword()).toEqual(true); expect(result.isFailed()).toBe(true); }); + + it("should handle network failure during sign-up start", async () => { + const networkError = new Error("Network failure"); + networkError.name = "NetworkError"; + signUpApiClient.start.mockRejectedValue(networkError); + + 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).toBeInstanceOf(SignUpError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-up with custom attributes", 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", + attributes: { + firstName: "John", + lastName: "Doe", + company: "Test Corp", + }, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should handle sign-up with both password and attributes", 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", + password: "test-password", + attributes: { + firstName: "Jane", + lastName: "Smith", + }, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should handle sign-up with invalid attribute format", async () => { + const invalidAttributeError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid attribute format", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(invalidAttributeError); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + attributes: { + invalidAttribute: null, + } as any, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-up when user is already signed in", async () => { + // Mock that a user is already cached + jest.spyOn(controller, "getCurrentAccount").mockReturnValue({ + data: {} as any, + error: undefined, + } as any); + + 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.isFailed()).toBe(true); + }); + + it("should handle username already exists error", async () => { + const userExistsError = new CustomAuthApiError( + CustomAuthApiErrorCode.USER_ALREADY_EXISTS, + "Username already exists", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(userExistsError); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "existing@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle server internal error during sign-up", async () => { + const serverError = new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + "Internal server error", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(serverError); + + 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.isFailed()).toBe(true); + }); + + it("should handle malformed challenge response during sign-up", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + // Missing required challenge_type field + 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).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); }); describe("resetPassword", () => { @@ -407,5 +817,196 @@ describe("CustomAuthStandardController", () => { expect(result.error?.isUserNotFound()).toEqual(true); expect(result.isFailed()).toBe(true); }); + + it("should handle network connectivity issues during reset password", async () => { + const connectivityError = new Error("No internet connection"); + connectivityError.name = "ConnectivityError"; + resetPasswordApiClient.start.mockRejectedValue(connectivityError); + + 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.isFailed()).toBe(true); + }); + + it("should handle malformed username in reset password", async () => { + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "not-an-email", + }; + + // Mock API rejecting malformed username + const malformedError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Username format is invalid", + "correlation-id" + ); + resetPasswordApiClient.start.mockRejectedValue(malformedError); + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle server maintenance error during reset password", async () => { + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const maintenanceError = new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + "Service temporarily unavailable", + "correlation-id" + ); + resetPasswordApiClient.start.mockRejectedValue(maintenanceError); + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle unauthorized access during reset password", async () => { + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const unauthorizedError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Unauthorized access", + "correlation-id" + ); + resetPasswordApiClient.start.mockRejectedValue(unauthorizedError); + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle server maintenance error during reset password", async () => { + const maintenanceError = new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + "Service temporarily unavailable", + "correlation-id" + ); + resetPasswordApiClient.start.mockRejectedValue(maintenanceError); + + 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.isFailed()).toBe(true); + }); + + it("should handle unauthorized access during reset password", async () => { + const unauthorizedError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Unauthorized access", + "correlation-id" + ); + resetPasswordApiClient.start.mockRejectedValue(unauthorizedError); + + 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.isFailed()).toBe(true); + }); + + it("should handle reset password when user is already signed in", async () => { + // Mock that a user is already cached + jest.spyOn(controller, "getCurrentAccount").mockReturnValue({ + data: {} as any, + error: undefined, + } as any); + + 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.isFailed()).toBe(true); + }); + + it("should handle null resetPasswordInputs gracefully", async () => { + const result = await controller.resetPassword(null as any); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle API error during reset password", async () => { + // Setup the API to throw an error + const apiError = new Error("API error during reset password"); + apiError.name = "CustomAuthApiError"; + resetPasswordApiClient.start.mockRejectedValue(apiError); + + 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.isFailed()).toBe(true); + }); + }); + + describe("getCurrentAccount", () => { + it("should handle getCurrentAccount with correlationId in inputs", () => { + const inputs = { + correlationId: "test-correlation-id", + }; + + const result = controller.getCurrentAccount(inputs); + + expect(result).toBeDefined(); + // Should return empty result when no account is cached + expect(result.data).toBeUndefined(); + }); + + it("should handle getCurrentAccount without inputs", () => { + const result = controller.getCurrentAccount(); + + expect(result).toBeDefined(); + expect(result.data).toBeUndefined(); + }); + + it("should handle getCurrentAccount with null inputs", () => { + const result = controller.getCurrentAccount(null as any); + + expect(result).toBeDefined(); + expect(result.data).toBeUndefined(); + }); }); }); diff --git a/lib/msal-browser/test/custom_auth/core/auth_flow/AuthFlowResultBase.spec.ts b/lib/msal-browser/test/custom_auth/core/auth_flow/AuthFlowResultBase.spec.ts new file mode 100644 index 0000000000..169a1b5b51 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/auth_flow/AuthFlowResultBase.spec.ts @@ -0,0 +1,442 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AuthError, + ServerError, + InteractionRequiredAuthError, +} from "@azure/msal-common/browser"; +import { AuthFlowResultBase } from "../../../../src/custom_auth/core/auth_flow/AuthFlowResultBase.js"; +import { AuthFlowErrorBase } from "../../../../src/custom_auth/core/auth_flow/AuthFlowErrorBase.js"; +import { AuthFlowStateBase } from "../../../../src/custom_auth/core/auth_flow/AuthFlowState.js"; +import { CustomAuthError } from "../../../../src/custom_auth/core/error/CustomAuthError.js"; +import { MsalCustomAuthError } from "../../../../src/custom_auth/core/error/MsalCustomAuthError.js"; +import { UnexpectedError } from "../../../../src/custom_auth/core/error/UnexpectedError.js"; + +// Mock implementations for testing +class MockState extends AuthFlowStateBase {} + +class MockError extends AuthFlowErrorBase { + constructor(errorData: CustomAuthError) { + super(errorData); + } +} + +class MockAuthFlowResult extends AuthFlowResultBase< + MockState, + MockError, + string +> { + constructor(state: MockState, data?: string) { + super(state, data); + } + + static testCreateErrorData(error: unknown): CustomAuthError { + return MockAuthFlowResult.createErrorData(error); + } +} + +describe("AuthFlowResultBase", () => { + let mockState: MockState; + + beforeEach(() => { + mockState = new MockState(); + }); + + describe("constructor", () => { + it("should create an instance with state and no data", () => { + const result = new MockAuthFlowResult(mockState); + + expect(result.state).toBe(mockState); + expect(result.data).toBeUndefined(); + expect(result.error).toBeUndefined(); + }); + + it("should create an instance with state and data", () => { + const testData = "test data"; + const result = new MockAuthFlowResult(mockState, testData); + + expect(result.state).toBe(mockState); + expect(result.data).toBe(testData); + expect(result.error).toBeUndefined(); + }); + + it("should allow setting error after construction", () => { + const result = new MockAuthFlowResult(mockState); + const mockErrorData = new CustomAuthError( + "test_error", + "Test error description" + ); + const mockError = new MockError(mockErrorData); + + result.error = mockError; + + expect(result.error).toBe(mockError); + }); + }); + + describe("createErrorData", () => { + describe("when error is CustomAuthError", () => { + it("should return the same CustomAuthError instance", () => { + const customError = new CustomAuthError( + "custom_error", + "Custom error description", + "correlation-id", + [1001, 1002], + "sub_error" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(customError); + + expect(result).toBe(customError); + expect(result.error).toBe("custom_error"); + expect(result.errorDescription).toBe( + "Custom error description" + ); + expect(result.correlationId).toBe("correlation-id"); + expect(result.errorCodes).toEqual([1001, 1002]); + expect(result.subError).toBe("sub_error"); + }); + }); + + describe("when error is AuthError", () => { + it("should convert AuthError to MsalCustomAuthError", () => { + const authError = new AuthError( + "auth_error_code", + "Auth error message", + "auth_sub_error" + ); + authError.setCorrelationId("auth-correlation-id"); + + const result = + MockAuthFlowResult.testCreateErrorData(authError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.error).toBe("auth_error_code"); + expect(result.errorDescription).toBe("Auth error message"); + expect(result.subError).toBe("auth_sub_error"); + expect(result.correlationId).toBe("auth-correlation-id"); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle AuthError with string errorNo property", () => { + const authError = new AuthError( + "auth_error_code", + "Auth error message" + ) as any; + authError.errorNo = "1234"; + + const result = + MockAuthFlowResult.testCreateErrorData(authError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([1234]); + }); + + it("should handle AuthError with numeric errorNo property", () => { + const authError = new AuthError( + "auth_error_code", + "Auth error message" + ) as any; + authError.errorNo = 5678; + + const result = + MockAuthFlowResult.testCreateErrorData(authError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([5678]); + }); + + it("should handle ServerError with string errorNo property", () => { + const serverError = new ServerError( + "server_error_code", + "Server error message", + undefined, + "9999" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(serverError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([9999]); + expect(result.error).toBe("server_error_code"); + expect(result.errorDescription).toBe("Server error message"); + }); + + it("should handle ServerError with numeric errorNo as string", () => { + const serverError = new ServerError( + "server_error_code", + "Server error message", + undefined, + "8888" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(serverError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([8888]); + }); + + it("should handle InteractionRequiredAuthError with string errorNo property", () => { + const interactionError = new InteractionRequiredAuthError( + "interaction_required", + "Interaction required message", + undefined, + undefined, + undefined, + undefined, + undefined, + "7777" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(interactionError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([7777]); + expect(result.error).toBe("interaction_required"); + expect(result.errorDescription).toBe( + "Interaction required message" + ); + }); + + it("should handle InteractionRequiredAuthError with numeric errorNo as string", () => { + const interactionError = new InteractionRequiredAuthError( + "interaction_required", + "Interaction required message", + undefined, + undefined, + undefined, + undefined, + undefined, + "6666" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(interactionError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([6666]); + }); + + it("should handle AuthError with invalid string errorNo property", () => { + const authError = new AuthError( + "auth_error_code", + "Auth error message" + ) as any; + authError.errorNo = "invalid_number"; + + const result = + MockAuthFlowResult.testCreateErrorData(authError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle ServerError with invalid string errorNo property", () => { + const serverError = new ServerError( + "server_error_code", + "Server error message", + undefined, + "invalid_number" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(serverError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle InteractionRequiredAuthError with invalid string errorNo property", () => { + const interactionError = new InteractionRequiredAuthError( + "interaction_required", + "Interaction required message", + undefined, + undefined, + undefined, + undefined, + undefined, + "invalid_number" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(interactionError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle AuthError without errorNo property", () => { + const authError = new AuthError( + "auth_error_code", + "Auth error message" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(authError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle ServerError without errorNo property", () => { + const serverError = new ServerError( + "server_error_code", + "Server error message" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(serverError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + + it("should handle InteractionRequiredAuthError without errorNo property", () => { + const interactionError = new InteractionRequiredAuthError( + "interaction_required", + "Interaction required message" + ); + + const result = + MockAuthFlowResult.testCreateErrorData(interactionError); + + expect(result).toBeInstanceOf(MsalCustomAuthError); + expect(result.errorCodes).toEqual([]); + }); + }); + + describe("when error is a standard Error", () => { + it("should convert Error to UnexpectedError", () => { + const standardError = new Error("Standard error message"); + + const result = + MockAuthFlowResult.testCreateErrorData(standardError); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe("Standard error message"); + }); + }); + + describe("when error is a string", () => { + it("should convert string to UnexpectedError", () => { + const errorString = "String error message"; + + const result = + MockAuthFlowResult.testCreateErrorData(errorString); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe("String error message"); + }); + }); + + describe("when error is an object", () => { + it("should convert object to UnexpectedError with JSON string", () => { + const errorObject = { + code: "error_code", + message: "Error message", + }; + + const result = + MockAuthFlowResult.testCreateErrorData(errorObject); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe( + JSON.stringify(errorObject) + ); + }); + }); + + describe("when error is null", () => { + it("should convert null to UnexpectedError with default message", () => { + const result = MockAuthFlowResult.testCreateErrorData(null); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe( + "An unexpected error occurred." + ); + }); + }); + + describe("when error is undefined", () => { + it("should convert undefined to UnexpectedError with default message", () => { + const result = + MockAuthFlowResult.testCreateErrorData(undefined); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe( + "An unexpected error occurred." + ); + }); + }); + + describe("when error is a number", () => { + it("should convert number to UnexpectedError with default message", () => { + const result = MockAuthFlowResult.testCreateErrorData(42); + + expect(result).toBeInstanceOf(UnexpectedError); + expect(result.error).toBe("unexpected_error"); + expect(result.errorDescription).toBe( + "An unexpected error occurred." + ); + }); + }); + }); + + describe("inheritance and type safety", () => { + it("should maintain correct type relationships", () => { + const result = new MockAuthFlowResult(mockState, "test data"); + + expect(result).toBeInstanceOf(AuthFlowResultBase); + expect(result.state).toBeInstanceOf(AuthFlowStateBase); + }); + + it("should allow derived classes to access protected createErrorData method", () => { + // This test ensures the protected method is accessible to derived classes + const customError = new CustomAuthError("test_error"); + const result = MockAuthFlowResult.testCreateErrorData(customError); + + expect(result).toBe(customError); + }); + }); + + describe("generic type parameters", () => { + it("should enforce correct state type", () => { + const result = new MockAuthFlowResult(mockState); + + // TypeScript should enforce that result.state is of type MockState + expect(result.state).toBeInstanceOf(MockState); + }); + + it("should enforce correct data type", () => { + const testData = "string data"; + const result = new MockAuthFlowResult(mockState, testData); + + // TypeScript should enforce that result.data is of type string + expect(typeof result.data).toBe("string"); + expect(result.data).toBe(testData); + }); + + it("should enforce correct error type", () => { + const result = new MockAuthFlowResult(mockState); + const mockErrorData = new CustomAuthError("test_error"); + const mockError = new MockError(mockErrorData); + + result.error = mockError; + + // TypeScript should enforce that result.error is of type MockError + expect(result.error).toBeInstanceOf(MockError); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.spec.ts b/lib/msal-browser/test/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.spec.ts new file mode 100644 index 0000000000..b4f78e38ce --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.spec.ts @@ -0,0 +1,168 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AuthMethodRegistrationChallengeMethodError, + AuthMethodRegistrationSubmitChallengeError, +} from "../../../../../../src/custom_auth/core/auth_flow/jit/error_type/AuthMethodRegistrationError.js"; +import { CustomAuthError } from "../../../../../../src/custom_auth/core/error/CustomAuthError.js"; +import { InvalidArgumentError } from "../../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { + CustomAuthApiError, + RedirectError, +} from "../../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import * as CustomAuthApiSuberror from "../../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.js"; + +describe("JitError", () => { + const mockErrorData = { + error: "test_error", + errorDescription: "Test error description", + }; + + describe("AuthMethodRegistrationChallengeMethodError", () => { + it("should be instance of base error class", () => { + const customAuthError = new CustomAuthError("test", "test"); + const error = new AuthMethodRegistrationChallengeMethodError( + customAuthError + ); + + expect(error).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodError + ); + expect(error.errorData).toBe(customAuthError); + }); + + it("should return true for isRedirectRequired when redirect error", () => { + const redirectError = new RedirectError(mockErrorData as any); + const error = new AuthMethodRegistrationChallengeMethodError( + redirectError + ); + + expect(error.isRedirectRequired()).toBe(true); + }); + + it("should return false for isRedirectRequired when not redirect error", () => { + const customAuthError = new CustomAuthApiError( + "invalid_request", + "Invalid request", + "correlation-id", + [] + ); + const error = new AuthMethodRegistrationChallengeMethodError( + customAuthError + ); + + expect(error.isRedirectRequired()).toBe(false); + }); + + it("should return false for isIncorrectVerificationContact (placeholder)", () => { + const customAuthError = new CustomAuthError("test", "test"); + const error = new AuthMethodRegistrationChallengeMethodError( + customAuthError + ); + + expect(error.isInvalidInput()).toBe(false); + }); + + it("should return true for isIncorrectVerificationContact when API error with error code 901001", () => { + const apiError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Incorrect verification contact", + "correlation-id", + [901001] + ); + const error = new AuthMethodRegistrationChallengeMethodError( + apiError + ); + + expect(error.isInvalidInput()).toBe(true); + }); + + it("should return false for isIncorrectVerificationContact when different error code", () => { + const apiError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Other error", + "correlation-id", + [50126] + ); + const error = new AuthMethodRegistrationChallengeMethodError( + apiError + ); + + expect(error.isInvalidInput()).toBe(false); + }); + }); + + describe("AuthMethodRegistrationSubmitChallengeError", () => { + it("should be instance of base error class", () => { + const customAuthError = new CustomAuthError("test", "test"); + const error = new AuthMethodRegistrationSubmitChallengeError( + customAuthError + ); + + expect(error).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeError + ); + expect(error.errorData).toBe(customAuthError); + }); + + it("should return true for isRedirectRequired when redirect error", () => { + const redirectError = new RedirectError(mockErrorData as any); + const error = new AuthMethodRegistrationSubmitChallengeError( + redirectError + ); + + expect(error.isRedirectRequired()).toBe(true); + }); + + it("should return false for isRedirectRequired when not redirect error", () => { + const customAuthError = new CustomAuthApiError( + "invalid_request", + "Invalid request", + "correlation-id", + [] + ); + const error = new AuthMethodRegistrationSubmitChallengeError( + customAuthError + ); + + expect(error.isRedirectRequired()).toBe(false); + }); + + it("should return true for isIncorrectChallenge when invalid code error with API error", () => { + const apiError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "test description", + "correlationId", + [], + CustomAuthApiSuberror.INVALID_OOB_VALUE + ); + const error = new AuthMethodRegistrationSubmitChallengeError( + apiError + ); + + expect(error.isIncorrectChallenge()).toBe(true); + }); + + it("should return true for isIncorrectChallenge when invalid argument error with code", () => { + const argumentError = new InvalidArgumentError("code"); + const error = new AuthMethodRegistrationSubmitChallengeError( + argumentError + ); + + expect(error.isIncorrectChallenge()).toBe(true); + }); + + it("should return false for isIncorrectChallenge when other error", () => { + const customAuthError = new CustomAuthError("other_error", "test"); + const error = new AuthMethodRegistrationSubmitChallengeError( + customAuthError + ); + + expect(error.isIncorrectChallenge()).toBe(false); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.spec.ts b/lib/msal-browser/test/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.spec.ts new file mode 100644 index 0000000000..c920891bdf --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.spec.ts @@ -0,0 +1,253 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AuthMethodRegistrationRequiredState, + AuthMethodVerificationRequiredState, +} from "../../../../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { + AuthMethodRegistrationRequiredStateParameters, + AuthMethodVerificationRequiredStateParameters, +} from "../../../../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationStateParameters.js"; +import { AuthMethodDetails } from "../../../../../../src/custom_auth/core/auth_flow/jit/AuthMethodDetails.js"; +import { JitClient } from "../../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { CustomAuthBrowserConfiguration } from "../../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { Logger } from "@azure/msal-common/browser"; +import { + createJitVerificationRequiredResult, + createJitCompletedResult, +} from "../../../../../../src/custom_auth/core/interaction_client/jit/result/JitActionResult.js"; +import { AuthenticationMethod } from "../../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.js"; + +describe("JitState", () => { + let mockJitClient: jest.Mocked; + let mockCacheClient: jest.Mocked; + let mockConfig: CustomAuthBrowserConfiguration; + let mockLogger: jest.Mocked; + let correlationId: string; + + const mockAuthMethod: AuthenticationMethod = { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }; + + beforeEach(() => { + correlationId = "test-correlation-id"; + + mockJitClient = { + challengeAuthMethod: jest.fn(), + submitChallenge: jest.fn(), + } as any; + + mockCacheClient = {} as any; + + mockConfig = { + customAuth: { + challengeTypes: ["oob"], + }, + } as any; + + mockLogger = { + verbose: jest.fn(), + error: jest.fn(), + } as any; + }); + + describe("AuthMethodRegistrationRequiredState", () => { + let state: AuthMethodRegistrationRequiredState; + let stateParameters: AuthMethodRegistrationRequiredStateParameters; + + beforeEach(() => { + stateParameters = { + correlationId, + logger: mockLogger, + config: mockConfig, + continuationToken: "test-token", + jitClient: mockJitClient, + cacheClient: mockCacheClient, + scopes: ["scope1"], + username: "testuser", + authMethods: [mockAuthMethod], + }; + + state = new AuthMethodRegistrationRequiredState(stateParameters); + }); + + it("should be created successfully", () => { + expect(state).toBeInstanceOf(AuthMethodRegistrationRequiredState); + }); + + it("should return available auth methods", () => { + const methods = state.getAuthMethods(); + expect(methods).toEqual([mockAuthMethod]); + }); + + it("should challenge auth method successfully and return verification required", async () => { + const authMethodDetails: AuthMethodDetails = { + authMethodType: mockAuthMethod, + verificationContact: "user@example.com", + }; + + const mockResult = createJitVerificationRequiredResult({ + correlationId, + continuationToken: "new-token", + challengeChannel: "email", + challengeTargetLabel: "u***@example.com", + codeLength: 6, + }); + + mockJitClient.challengeAuthMethod.mockResolvedValue(mockResult); + + const result = await state.challengeAuthMethod(authMethodDetails); + + expect(result.isVerificationRequired()).toBe(true); + expect(mockJitClient.challengeAuthMethod).toHaveBeenCalledWith({ + correlationId, + continuationToken: "test-token", + authMethod: mockAuthMethod, + verificationContact: "user@example.com", + scopes: ["scope1"], + username: "testuser", + }); + }); + + it("should challenge auth method successfully and return completed for fast-pass", async () => { + const authMethodDetails: AuthMethodDetails = { + authMethodType: mockAuthMethod, + verificationContact: "user@example.com", + }; + + const mockResult = createJitCompletedResult({ + correlationId, + authenticationResult: { + account: { + homeAccountId: "test-account-id", + environment: "test-env", + tenantId: "test-tenant", + username: "testuser", + localAccountId: "test-local-id", + }, + } as any, + }); + + mockJitClient.challengeAuthMethod.mockResolvedValue(mockResult); + + const result = await state.challengeAuthMethod(authMethodDetails); + + expect(result.isCompleted()).toBe(true); + }); + + it("should handle challenge auth method error", async () => { + const authMethodDetails: AuthMethodDetails = { + authMethodType: mockAuthMethod, + verificationContact: "user@example.com", + }; + + const error = new Error("Test error"); + mockJitClient.challengeAuthMethod.mockRejectedValue(error); + + const result = await state.challengeAuthMethod(authMethodDetails); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); + }); + }); + + describe("AuthMethodVerificationRequiredState", () => { + let state: AuthMethodVerificationRequiredState; + let stateParameters: AuthMethodVerificationRequiredStateParameters; + + beforeEach(() => { + stateParameters = { + correlationId, + logger: mockLogger, + config: mockConfig, + continuationToken: "test-token", + jitClient: mockJitClient, + cacheClient: mockCacheClient, + scopes: ["scope1"], + username: "testuser", + challengeChannel: "email", + challengeTargetLabel: "u***@example.com", + codeLength: 6, + }; + + state = new AuthMethodVerificationRequiredState(stateParameters); + }); + + it("should be created successfully", () => { + expect(state).toBeInstanceOf(AuthMethodVerificationRequiredState); + }); + + it("should return correct challenge details", () => { + expect(state.getCodeLength()).toBe(6); + expect(state.getChannel()).toBe("email"); + expect(state.getSentTo()).toBe("u***@example.com"); + }); + + it("should submit challenge successfully", async () => { + const mockResult = createJitCompletedResult({ + correlationId, + authenticationResult: { + account: { + homeAccountId: "test-account-id", + environment: "test-env", + tenantId: "test-tenant", + username: "testuser", + localAccountId: "test-local-id", + }, + } as any, + }); + + mockJitClient.submitChallenge.mockResolvedValue(mockResult); + + const result = await state.submitChallenge("123456"); + + expect(result.isCompleted()).toBe(true); + expect(mockJitClient.submitChallenge).toHaveBeenCalledWith({ + correlationId, + continuationToken: "test-token", + scopes: ["scope1"], + grantType: "oob", + challenge: "123456", + username: "testuser", + }); + }); + + it("should handle submit challenge error", async () => { + const error = new Error("Test error"); + mockJitClient.submitChallenge.mockRejectedValue(error); + + const result = await state.submitChallenge("123456"); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); + }); + + it("should challenge different auth method", async () => { + const authMethodDetails: AuthMethodDetails = { + authMethodType: mockAuthMethod, + verificationContact: "different@example.com", + }; + + const mockResult = createJitVerificationRequiredResult({ + correlationId, + continuationToken: "new-token", + challengeChannel: "email", + challengeTargetLabel: "d***@example.com", + codeLength: 6, + }); + + mockJitClient.challengeAuthMethod.mockResolvedValue(mockResult); + + const result = await state.challengeAuthMethod(authMethodDetails); + + expect(result.isVerificationRequired()).toBe(true); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/interaction_client/jit/JitClient.spec.ts b/lib/msal-browser/test/custom_auth/core/interaction_client/jit/JitClient.spec.ts new file mode 100644 index 0000000000..e1ed2d557e --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/interaction_client/jit/JitClient.spec.ts @@ -0,0 +1,364 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { + JitGetAuthMethodsParams, + JitChallengeAuthMethodParams, + JitSubmitChallengeParams, +} from "../../../../../src/custom_auth/core/interaction_client/jit/parameter/JitParams.js"; +import { + JIT_GET_AUTH_METHODS_RESULT_TYPE, + JIT_VERIFICATION_REQUIRED_RESULT_TYPE, + JIT_COMPLETED_RESULT_TYPE, +} from "../../../../../src/custom_auth/core/interaction_client/jit/result/JitActionResult.js"; +import { + ChallengeType, + GrantType, +} from "../../../../../src/custom_auth/CustomAuthConstants.js"; +import { AuthenticationMethod } from "../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { customAuthConfig } from "../../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { StubbedNetworkModule } from "@azure/msal-common/browser"; +import { buildConfiguration } from "../../../../../src/config/Configuration.js"; +import { + getDefaultBrowserCacheManager, + getDefaultCrypto, + getDefaultEventHandler, + getDefaultLogger, + getDefaultNavigationClient, + getDefaultPerformanceClient, +} from "../../../test_resources/TestModules.js"; +import { TestServerTokenResponse } from "../../../test_resources/TestConstants.js"; + +jest.mock( + "../../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js", + () => { + let registerApiClient = { + introspect: jest.fn(), + challenge: jest.fn(), + continue: jest.fn(), + }; + let signInApiClient = { + requestTokenWithContinuationToken: jest.fn(), + }; + + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + registerApi: registerApiClient, + signInApi: signInApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { + mockedApiClient, + registerApiClient, + signInApiClient, + }; + } +); + +const { + mockedApiClient, + registerApiClient, + signInApiClient, +} = require("../../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js"); + +describe("JitClient", () => { + let jitClient: JitClient; + let customAuthAuthority: CustomAuthAuthority; + const mockCorrelationId = "test-correlation-id"; + const mockContinuationToken = "test-continuation-token"; + + beforeEach(async () => { + jest.clearAllMocks(); + + const mockBrowserConfiguration = buildConfiguration( + customAuthConfig, + true + ); + const mockCacheManager = getDefaultBrowserCacheManager(); + const mockLogger = getDefaultLogger(); + + customAuthAuthority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockBrowserConfiguration, + StubbedNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + jitClient = new JitClient( + mockBrowserConfiguration, + mockCacheManager, + getDefaultCrypto(), + mockLogger, + getDefaultEventHandler(), + getDefaultNavigationClient(), + getDefaultPerformanceClient(), + mockedApiClient, + customAuthAuthority + ); + }); + + describe("getAuthMethods", () => { + it("should call introspect endpoint and return auth methods", async () => { + const mockAuthMethods: AuthenticationMethod[] = [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ]; + + const mockIntrospectResponse = { + correlation_id: mockCorrelationId, + continuation_token: mockContinuationToken, + methods: mockAuthMethods, + }; + + registerApiClient.introspect.mockResolvedValue( + mockIntrospectResponse + ); + + const params: JitGetAuthMethodsParams = { + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + }; + + const result = await jitClient.getAuthMethods(params); + + expect(result.type).toBe(JIT_GET_AUTH_METHODS_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + expect(result.continuationToken).toBe(mockContinuationToken); + expect(result.authMethods).toBe(mockAuthMethods); + expect(registerApiClient.introspect).toHaveBeenCalledWith({ + continuation_token: mockContinuationToken, + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + }); + }); + + describe("challengeAuthMethod", () => { + const mockAuthMethod: AuthenticationMethod = { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }; + + const mockParams: JitChallengeAuthMethodParams = { + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + authMethod: mockAuthMethod, + verificationContact: "user@example.com", + scopes: ["openid"], + }; + + it("should handle fast-pass scenario and return completed result", async () => { + const mockChallengeResponse = { + correlation_id: mockCorrelationId, + continuation_token: "challenge-continuation-token", + challenge_type: ChallengeType.PREVERIFIED, + challenge_channel: "email", + challenge_target: "user@example.com", + binding_method: "prompt", + }; + + const mockContinueResponse = { + correlation_id: mockCorrelationId, + continuation_token: "final-continuation-token", + }; + + const mockTokenResponse = { + ...TestServerTokenResponse, + correlation_id: mockCorrelationId, + }; + + registerApiClient.challenge.mockResolvedValue( + mockChallengeResponse + ); + registerApiClient.continue.mockResolvedValue(mockContinueResponse); + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + mockTokenResponse + ); + + const result = await jitClient.challengeAuthMethod(mockParams); + + expect(result.type).toBe(JIT_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + + // Verify that continue endpoint is called with grant_type=continuation_token for fast-pass + expect(registerApiClient.continue).toHaveBeenCalledWith({ + continuation_token: "challenge-continuation-token", + grant_type: GrantType.CONTINUATION_TOKEN, + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + + // Verify that token endpoint is called with the new continuation token from continue response + expect( + signInApiClient.requestTokenWithContinuationToken + ).toHaveBeenCalledWith({ + continuation_token: "final-continuation-token", + scope: "openid", + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + }); + + it("should handle verification required scenario", async () => { + const mockChallengeResponse = { + correlation_id: mockCorrelationId, + continuation_token: "new-continuation-token", + challenge_type: ChallengeType.OOB, + challenge_channel: "email", + challenge_target: "user@example.com", + binding_method: "prompt", + code_length: 6, + }; + + registerApiClient.challenge.mockResolvedValue( + mockChallengeResponse + ); + + const result = await jitClient.challengeAuthMethod(mockParams); + + expect(result.type).toBe(JIT_VERIFICATION_REQUIRED_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + + if (result.type === JIT_VERIFICATION_REQUIRED_RESULT_TYPE) { + expect(result.continuationToken).toBe("new-continuation-token"); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("user@example.com"); + expect(result.codeLength).toBe(6); + } + }); + + it("should call continue endpoint with correct grant_type for fast-pass flow", async () => { + const mockChallengeResponse = { + correlation_id: mockCorrelationId, + continuation_token: "preverified-token", + challenge_type: ChallengeType.PREVERIFIED, + challenge_channel: "email", + challenge_target: "user@example.com", + binding_method: "prompt", + }; + + const mockContinueResponse = { + correlation_id: mockCorrelationId, + continuation_token: "post-continue-token", + }; + + const mockTokenResponse = { + ...TestServerTokenResponse, + correlation_id: mockCorrelationId, + }; + + registerApiClient.challenge.mockResolvedValue( + mockChallengeResponse + ); + registerApiClient.continue.mockResolvedValue(mockContinueResponse); + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + mockTokenResponse + ); + + await jitClient.challengeAuthMethod(mockParams); + + // Verify the continue endpoint is called with grant_type=continuation_token (not oob) + expect(registerApiClient.continue).toHaveBeenCalledTimes(1); + expect(registerApiClient.continue).toHaveBeenCalledWith({ + continuation_token: "preverified-token", + grant_type: GrantType.CONTINUATION_TOKEN, + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + + // Verify no oob parameter is passed for fast-pass + const continueCall = registerApiClient.continue.mock.calls[0][0]; + expect(continueCall.oob).toBeUndefined(); + }); + }); + + describe("submitChallenge", () => { + it("should submit challenge and return completed result", async () => { + const mockParams: JitSubmitChallengeParams = { + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + challenge: "123456", + grantType: "oob", + scopes: ["openid"], + }; + + const mockContinueResponse = { + correlation_id: mockCorrelationId, + continuation_token: "final-continuation-token", + }; + + const mockTokenResponse = { + ...TestServerTokenResponse, + correlation_id: mockCorrelationId, + }; + + registerApiClient.continue.mockResolvedValue(mockContinueResponse); + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + mockTokenResponse + ); + + const result = await jitClient.submitChallenge(mockParams); + + expect(result.type).toBe(JIT_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + expect(registerApiClient.continue).toHaveBeenCalledWith({ + continuation_token: mockContinuationToken, + grant_type: GrantType.OOB, + oob: "123456", + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + expect( + signInApiClient.requestTokenWithContinuationToken + ).toHaveBeenCalled(); + }); + + it("should use grant_type from parameters for normal verification flow", async () => { + const mockParams: JitSubmitChallengeParams = { + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + challenge: "123456", + grantType: "oob", + scopes: ["openid"], + }; + + const mockContinueResponse = { + correlation_id: mockCorrelationId, + continuation_token: "final-continuation-token", + }; + + const mockTokenResponse = { + ...TestServerTokenResponse, + correlation_id: mockCorrelationId, + }; + + registerApiClient.continue.mockResolvedValue(mockContinueResponse); + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + mockTokenResponse + ); + + await jitClient.submitChallenge(mockParams); + + // Verify that continue is called with the grant_type from parameters (oob for normal flow) + expect(registerApiClient.continue).toHaveBeenCalledWith({ + continuation_token: mockContinuationToken, + grant_type: GrantType.OOB, + oob: "123456", + correlationId: mockCorrelationId, + telemetryManager: expect.any(Object), + }); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/interaction_client/jit/result/JitActionResult.spec.ts b/lib/msal-browser/test/custom_auth/core/interaction_client/jit/result/JitActionResult.spec.ts new file mode 100644 index 0000000000..d1799349ba --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/interaction_client/jit/result/JitActionResult.spec.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + createJitGetAuthMethodsResult, + createJitVerificationRequiredResult, + createJitCompletedResult, + JIT_GET_AUTH_METHODS_RESULT_TYPE, + JIT_VERIFICATION_REQUIRED_RESULT_TYPE, + JIT_COMPLETED_RESULT_TYPE, + JitGetAuthMethodsResult, + JitVerificationRequiredResult, + JitCompletedResult, +} from "../../../../../../src/custom_auth/core/interaction_client/jit/result/JitActionResult.js"; +import { AuthenticationMethod } from "../../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { AuthenticationResult } from "../../../../../../src/response/AuthenticationResult.js"; + +describe("JitActionResult", () => { + const mockCorrelationId = "test-correlation-id"; + const mockContinuationToken = "test-continuation-token"; + const mockAuthMethods: AuthenticationMethod[] = [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ]; + const mockAuthResult = {} as AuthenticationResult; + + describe("createJitGetAuthMethodsResult", () => { + it("should create JitGetAuthMethodsResult with correct type", () => { + const result: JitGetAuthMethodsResult = + createJitGetAuthMethodsResult({ + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + authMethods: mockAuthMethods, + }); + + expect(result.type).toBe(JIT_GET_AUTH_METHODS_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + expect(result.continuationToken).toBe(mockContinuationToken); + expect(result.authMethods).toBe(mockAuthMethods); + }); + }); + + describe("createJitVerificationRequiredResult", () => { + it("should create JitVerificationRequiredResult with correct type", () => { + const result: JitVerificationRequiredResult = + createJitVerificationRequiredResult({ + correlationId: mockCorrelationId, + continuationToken: mockContinuationToken, + challengeChannel: "email", + challengeTargetLabel: "user@example.com", + codeLength: 6, + }); + + expect(result.type).toBe(JIT_VERIFICATION_REQUIRED_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + expect(result.continuationToken).toBe(mockContinuationToken); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("user@example.com"); + expect(result.codeLength).toBe(6); + }); + }); + + describe("createJitCompletedResult", () => { + it("should create JitCompletedResult with correct type", () => { + const result: JitCompletedResult = createJitCompletedResult({ + correlationId: mockCorrelationId, + authenticationResult: mockAuthResult, + }); + + expect(result.type).toBe(JIT_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe(mockCorrelationId); + expect(result.authenticationResult).toBe(mockAuthResult); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts index 250c2358c5..23d280128c 100644 --- a/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts +++ b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts @@ -26,6 +26,10 @@ describe("CustomAuthApiClient", () => { expect(customAuthApiClient.resetPasswordApi).toBeDefined(); }); + it("should initialize registerApiClient correctly", () => { + expect(customAuthApiClient.registerApi).toBeDefined(); + }); + describe("customAuthApiQueryParams", () => { it("should initialize with customAuthApiQueryParams containing dc", () => { const logger = getDefaultLogger(); diff --git a/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.spec.ts b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.spec.ts new file mode 100644 index 0000000000..642c824f0e --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.spec.ts @@ -0,0 +1,274 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { RegisterApiClient } from "../../../../../src/custom_auth/core/network_client/custom_auth_api/RegisterApiClient.js"; +import { FetchHttpClient } from "../../../../../src/custom_auth/core/network_client/http_client/FetchHttpClient.js"; +import { getDefaultLogger } from "../../../test_resources/TestModules.js"; +import { ServerTelemetryManager } from "@azure/msal-common/browser"; +import { CustomAuthApiError } from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import { GrantType } from "../../../../../src/custom_auth/CustomAuthConstants.js"; + +describe("RegisterApiClient", () => { + let registerApiClient: RegisterApiClient; + let mockHttpClient: jest.Mocked; + let mockTelemetryManager: jest.Mocked; + + const baseUrl = "https://test.com"; + const clientId = "test-client-id"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + beforeEach(() => { + const logger = getDefaultLogger(); + + // Create a real FetchHttpClient and then mock its methods + const realHttpClient = new FetchHttpClient(logger); + mockHttpClient = realHttpClient as jest.Mocked; + mockHttpClient.post = jest.fn(); + mockHttpClient.get = jest.fn(); + mockHttpClient.sendAsync = jest.fn(); + + mockTelemetryManager = { + generateCurrentRequestHeaderValue: jest + .fn() + .mockReturnValue("current-telemetry"), + generateLastRequestHeaderValue: jest + .fn() + .mockReturnValue("last-telemetry"), + } as any; + + registerApiClient = new RegisterApiClient( + baseUrl, + clientId, + mockHttpClient + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("introspect", () => { + it("should successfully call introspect endpoint and return available auth methods", async () => { + const mockResponse = { + continuation_token: "new-continuation-token", + correlation_id: correlationId, + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + const result = await registerApiClient.introspect({ + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + const [url] = mockHttpClient.post.mock.calls[0]; + expect(url.toString()).toBe(`${baseUrl}/register/v1.0/introspect`); + + expect(result).toEqual({ + ...mockResponse, + correlation_id: correlationId, + }); + }); + + it("should throw error when continuation token is missing in response", async () => { + const mockResponse = { + correlation_id: correlationId, + methods: [], + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + await expect( + registerApiClient.introspect({ + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }) + ).rejects.toThrow(CustomAuthApiError); + }); + }); + + describe("challenge", () => { + it("should successfully call challenge endpoint with required parameters", async () => { + const mockResponse = { + continuation_token: "new-continuation-token", + correlation_id: correlationId, + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + code_length: 6, + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + const result = await registerApiClient.challenge({ + continuation_token: continuationToken, + challenge_type: "oob", + challenge_target: "user@example.com", + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + const [url] = mockHttpClient.post.mock.calls[0]; + expect(url.toString()).toBe(`${baseUrl}/register/v1.0/challenge`); + + expect(result).toEqual({ + ...mockResponse, + correlation_id: correlationId, + }); + }); + + it("should include challenge_channel when provided", async () => { + const mockResponse = { + continuation_token: "new-continuation-token", + correlation_id: correlationId, + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + const result = await registerApiClient.challenge({ + continuation_token: continuationToken, + challenge_type: "oob", + challenge_target: "user@example.com", + challenge_channel: "email", + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + ...mockResponse, + correlation_id: correlationId, + }); + }); + }); + + describe("continue", () => { + it("should successfully call continue endpoint with OOB grant type", async () => { + const mockResponse = { + continuation_token: "final-continuation-token", + correlation_id: correlationId, + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + const result = await registerApiClient.continue({ + continuation_token: continuationToken, + grant_type: GrantType.OOB, + oob: "123456", + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + const [url] = mockHttpClient.post.mock.calls[0]; + expect(url.toString()).toBe(`${baseUrl}/register/v1.0/continue`); + + expect(result).toEqual({ + ...mockResponse, + correlation_id: correlationId, + }); + }); + + it("should work without oob parameter", async () => { + const mockResponse = { + continuation_token: "final-continuation-token", + correlation_id: correlationId, + }; + + mockHttpClient.post.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + const result = await registerApiClient.continue({ + continuation_token: continuationToken, + grant_type: GrantType.OOB, + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }); + + expect(mockHttpClient.post).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + ...mockResponse, + correlation_id: correlationId, + }); + }); + }); + + describe("error handling", () => { + it("should throw CustomAuthApiError when HTTP request fails", async () => { + mockHttpClient.post.mockRejectedValue(new Error("Network error")); + + await expect( + registerApiClient.introspect({ + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }) + ).rejects.toThrow(CustomAuthApiError); + }); + + it("should throw CustomAuthApiError when API returns error response", async () => { + const errorResponse = { + error: "invalid_request", + error_description: "Invalid continuation token", + correlation_id: correlationId, + }; + + mockHttpClient.post.mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue(errorResponse), + headers: new Map([["x-ms-request-id", correlationId]]), + } as any); + + await expect( + registerApiClient.introspect({ + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: mockTelemetryManager, + }) + ).rejects.toThrow(CustomAuthApiError); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts index fc95d7bd18..b20568ca5d 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts @@ -15,6 +15,10 @@ import { ResetPasswordCodeRequiredState } from "../../../src/custom_auth/reset_p import { ResetPasswordPasswordRequiredState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; import { ResetPasswordCompletedState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.js"; import { TestServerTokenResponse } from "../test_resources/TestConstants.js"; +import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; +import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; describe("Reset password", () => { let app: CustomAuthPublicClientApplication; @@ -50,6 +54,7 @@ describe("Reset password", () => { it("should reset password successfully if the new password is valid", async () => { (fetch as jest.Mock) + // Step 1: Mock /resetpassword/v1.0/start - successful start .mockResolvedValueOnce({ status: 200, json: async () => { @@ -60,6 +65,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 2: Mock /resetpassword/v1.0/challenge - successful challenge .mockResolvedValueOnce({ status: 200, json: async () => { @@ -75,6 +81,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 3: Mock /resetpassword/v1.0/continue - submit code successfully .mockResolvedValueOnce({ status: 200, json: async () => { @@ -86,6 +93,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 4: Mock /resetpassword/v1.0/submit - successful submit password .mockResolvedValueOnce({ status: 200, json: async () => { @@ -97,6 +105,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 5: Mock /resetpassword/v1.0/poll_completion - poll once and still in progress .mockResolvedValueOnce({ status: 200, json: async () => { @@ -107,6 +116,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 6: Mock /resetpassword/v1.0/poll_completion - poll twice and still in progress .mockResolvedValueOnce({ status: 200, json: async () => { @@ -117,6 +127,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 7: Mock /resetpassword/v1.0/poll_completion - poll three times and succeeded .mockResolvedValueOnce({ status: 200, json: async () => { @@ -128,6 +139,7 @@ describe("Reset password", () => { headers: new Headers({ "content-type": "application/json" }), ok: true, }) + // Step 8: Mock /oauth/v2.0/token - acquire tokens .mockResolvedValueOnce({ status: 200, json: async () => { @@ -178,9 +190,6 @@ describe("Reset password", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign in with custom claims after reset password successfully", async () => { @@ -324,9 +333,6 @@ describe("Reset password", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should reset password failed if the redirect challenge returned", async () => { @@ -395,4 +401,1094 @@ describe("Reset password", () => { expect(startResult.isFailed()).toBe(true); expect(startResult.error?.isUserNotFound()).toBe(true); }); + + it("should handle JIT registration required during reset password flow sign in", async () => { + (fetch as jest.Mock) + // Step 1: Mock /resetpassword/v1.0/start - successful start + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 2: Mock /resetpassword/v1.0/challenge - successful challenge + .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, + }) + // Step 3: Mock /resetpassword/v1.0/continue - submit code successfully + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 4: Mock /resetpassword/v1.0/submit - successful submit password + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 5: Mock /resetpassword/v1.0/poll_completion - poll and succeeded + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 6: Mock /oauth/v2.0/token - returns registration_required error + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS50076: Strong authentication is required.", + error_codes: [50076], + suberror: "registration_required", + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "jit-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // Step 7: Mock /register/v1.0/introspect - get available auth methods + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 8: Mock /register/v1.0/challenge - challenge auth method + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-3", + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + code_length: 6, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 9: Mock /register/v1.0/continue - submit challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-4", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 10: Mock /oauth/v2.0/token - successful token acquisition + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return TestServerTokenResponse; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // Attempt sign in - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + expect(jitState.getAuthMethods()).toHaveLength(1); + expect(jitState.getAuthMethods()[0].id).toBe("email"); + + // Challenge the authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@example.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + expect(verificationState.getCodeLength()).toBe(6); + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("user@example.com"); + + // Submit the verification challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeDefined(); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + + it("should handle JIT fast-pass scenario during reset password flow sign in", async () => { + (fetch as jest.Mock) + // Reset password flow mocks (Steps 1-5) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT flow mocks + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "AADSTS50076: Strong authentication is required.", + error_codes: [50076], + suberror: "registration_required", + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "test@test.com", // Same email as reset password + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Fast-pass challenge response + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-3", + challenge_type: "preverified", // Fast-pass scenario + binding_method: "none", + challenge_target: "test@test.com", + challenge_channel: "email", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Fast-pass continue response + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-4", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Token acquisition after fast-pass + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + + // Attempt sign in - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Challenge the same email as reset password (fast-pass scenario) + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "test@test.com", // Same email as reset password + }); + + // Fast-pass should complete immediately + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isCompleted()).toBe(true); + expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle incorrect verification contact during JIT registration", async () => { + (fetch as jest.Mock) + // Reset password flow mocks (abbreviated) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT registration_required error + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "AADSTS50076: Strong authentication is required.", + error_codes: [50076], + suberror: "registration_required", + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // JIT introspect + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Challenge with incorrect contact + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_request", + error_description: "The verification contact is incorrect.", + error_codes: [901001], + 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, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Challenge with incorrect verification contact + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "wrong@example.com", // Incorrect email + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeDefined(); + expect(challengeResult.isFailed()).toBe(true); + expect(challengeResult.error?.isInvalidInput()).toBe(true); + }); + + it("should handle incorrect challenge code during JIT verification", async () => { + (fetch as jest.Mock) + // Reset password and JIT setup mocks (abbreviated) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + suberror: "registration_required", + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-3", + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Incorrect challenge code + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "The out-of-band authentication is incorrect.", + error_codes: [50076], + 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, + }; + + // Complete reset password and JIT challenge + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@example.com", + }); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Submit incorrect verification code + const submitResult = await verificationState.submitChallenge( + "wrong-code" + ); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeDefined(); + expect(submitResult.isFailed()).toBe(true); + expect(submitResult.error?.isIncorrectChallenge()).toBe(true); + }); + + it("should handle resending JIT verification code during reset password flow", async () => { + (fetch as jest.Mock) + // Reset password setup (abbreviated) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT registration flow + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + suberror: "registration_required", + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Initial challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-3", + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Resend challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-4", + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "user@example.com", + challenge_channel: "email", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Submit correct code after resend + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-5", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password and get to JIT verification + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@example.com", + }); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Resend verification code + const resendResult = await verificationState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@example.com", + }); + + expect(resendResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(resendResult.error).toBeUndefined(); + expect(resendResult.isVerificationRequired()).toBe(true); + + const newVerificationState = + resendResult.state as AuthMethodVerificationRequiredState; + + // Submit the verification code + const submitResult = await newVerificationState.submitChallenge( + "123456" + ); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle redirect required error during JIT registration", async () => { + (fetch as jest.Mock) + // Reset password setup + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT registration_required + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + suberror: "registration_required", + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Redirect required during challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + challenge_type: "redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password and get to JIT + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Challenge method - should return redirect required + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@example.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeDefined(); + expect(challengeResult.isFailed()).toBe(true); + expect(challengeResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should handle JIT registration with custom claims during reset password flow", async () => { + (fetch as jest.Mock) + // Reset password setup + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + 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 () => ({ + continuation_token: "test-continuation-token-3", + expires_in: 600, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-5", + status: "succeeded", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Sign in with custom claims - registration required + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + suberror: "registration_required", + continuation_token: "jit-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // JIT flow with fast-pass + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "email", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "test@test.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-3", + challenge_type: "preverified", + binding_method: "none", + challenge_target: "test@test.com", + challenge_channel: "email", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-continuation-token-4", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password + const startResult = await app.resetPassword(resetPasswordInputs); + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + + const claims = JSON.stringify({ + access_token: { + acrs: { + essential: true, + value: "c1", + }, + }, + }); + + // Sign in with custom claims - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn({ + claims: claims, + }); + + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Use fast-pass with same email as reset password + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "test@test.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isCompleted()).toBe(true); + expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); }); diff --git a/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts index 14c584cb49..fc7a37a5a1 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts @@ -13,6 +13,10 @@ import { CustomAuthStandardController } from "../../../src/custom_auth/controlle import { SignInCodeRequiredState } from "../../../src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.js"; import { SignInPasswordRequiredState } from "../../../src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.js"; import { TestServerTokenResponse } from "../test_resources/TestConstants.js"; +import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; +import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; describe("Sign in", () => { let app: CustomAuthPublicClientApplication; @@ -93,9 +97,6 @@ describe("Sign in", () => { expect(result.isCompleted()).toBe(true); expect(result.data).toBeDefined(); expect(result.data).toBeInstanceOf(CustomAuthAccountData); - - // Sign out the user for clean up the state for the other tests. - result.data?.signOut(); }); it("should sign in successfully if the challenge type is oob", async () => { @@ -152,9 +153,6 @@ describe("Sign in", () => { expect(submitCodeResult).toBeDefined(); expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); - - // Sign out the user for clean up the state for the other tests. - submitCodeResult.data?.signOut(); }); it("should sign in successfully if the challenge type is password", async () => { @@ -211,9 +209,6 @@ describe("Sign in", () => { expect(submitCodeResult).toBeDefined(); expect(submitCodeResult).toBeInstanceOf(SignInSubmitPasswordResult); expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); - - // Sign out the user for clean up the state for the other tests. - submitCodeResult.data?.signOut(); }); it("should sign in failed with error if the challenge type is redirect", async () => { @@ -456,4 +451,519 @@ describe("Sign in", () => { expect(result.data).toBeDefined(); expect(result.data).toBeInstanceOf(CustomAuthAccountData); }); + + it("should handle JIT registration required after signIn() and complete flow with email verification", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /register/v1.0/continue - verification successful + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 7: Mock /oauth2/token - successful completion after JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.state).toBeInstanceOf( + AuthMethodRegistrationRequiredState + ); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Verify available authentication methods + const authMethods = jitState.getAuthMethods(); + expect(authMethods).toHaveLength(1); + expect(authMethods[0].id).toBe("email"); + expect(authMethods[0].login_hint).toBe("user@contoso.com"); + + // Challenge the email authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: authMethods[0], + verificationContact: "user@contoso.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isVerificationRequired()).toBe(true); + expect(challengeResult.state).toBeInstanceOf( + AuthMethodVerificationRequiredState + ); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Verify verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("us**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit verification code + const submitResult = await verificationState.submitChallenge("123456"); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle JIT registration with fast-pass scenario (same email as sign-up)", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - fast-pass (preverified) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "preverified", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /register/v1.0/continue - fast-pass registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 7: Mock /oauth2/token - successful completion after fast-pass JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method (same as sign-up email) + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + // Fast-pass should complete immediately + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isCompleted()).toBe(true); + expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle JIT registration error scenarios", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /register/v1.0/continue - incorrect verification code + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "The verification code is incorrect.", + suberror: "incorrect_challenge", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Submit incorrect verification code + const submitResult = await verificationState.submitChallenge( + "wrong-code" + ); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeDefined(); + expect(submitResult.isFailed()).toBe(true); + expect(submitResult.error?.isIncorrectChallenge()).toBe(true); + }); + + it("should handle resending JIT verification code", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - initial email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /register/v1.0/challenge - resend challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-resend-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Resend the challenge (equivalent to calling challengeAuthMethod again) + const resendResult = await verificationState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(resendResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(resendResult.error).toBeUndefined(); + expect(resendResult.isVerificationRequired()).toBe(true); + expect(resendResult.state).toBeInstanceOf( + AuthMethodVerificationRequiredState + ); + + const newVerificationState = + resendResult.state as AuthMethodVerificationRequiredState; + expect(newVerificationState.getChannel()).toBe("email"); + expect(newVerificationState.getSentTo()).toBe("us**@co***so.com"); + expect(newVerificationState.getCodeLength()).toBe(6); + }); }); diff --git a/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts index 593ec9c08f..7e34cd6c2b 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts @@ -19,6 +19,10 @@ import { SignUpCompletedState } from "../../../src/custom_auth/sign_up/auth_flow import { SignUpPasswordRequiredState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; import { SignUpAttributesRequiredState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; import { TestServerTokenResponse } from "../test_resources/TestConstants.js"; +import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; +import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; describe("Sign up", () => { let app: CustomAuthPublicClientApplication; @@ -170,9 +174,6 @@ describe("Sign up", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign in with custom claims after sign up successfully", async () => { @@ -304,9 +305,6 @@ describe("Sign up", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign up successfully if attributes are required after starting the password reset", async () => { @@ -449,9 +447,6 @@ describe("Sign up", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign up successfully if password and attributes are required after starting the password reset", async () => { @@ -624,9 +619,6 @@ describe("Sign up", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign up successfully if the password and attributes are provided when starting the password reset", async () => { @@ -713,9 +705,6 @@ describe("Sign up", () => { expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( TestServerTokenResponse.id_token ); - - // Sign out the user for clean up the state for the other tests. - signInResult.data?.signOut(); }); it("should sign up failed if the redirect challenge returned", async () => { @@ -784,4 +773,958 @@ describe("Sign up", () => { expect(startResult.isFailed()).toBe(true); expect(startResult.error?.isUserAlreadyExists()).toBe(true); }); + + it("should handle JIT registration required after signUp() completion and complete flow with email verification", async () => { + // Step 1: Mock /signup/v1.0/start - successful start + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /signup/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /signup/v1.0/continue - code submission (requires password) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /signup/v1.0/challenge - password requirement + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /signup/v1.0/continue - password submission (signup complete) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - JIT registration required during signIn after signup + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 7: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 8: Mock /register/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 9: Mock /register/v1.0/continue - challenge submission + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 10: Mock /oauth2/token - successful completion after JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Start SignUp flow + const startResult = await app.signUp(signUpInputs); + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + // Submit code + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + // Submit password + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // SignIn after signup completion - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + // Verify JIT registration is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + expect(jitState.getAuthMethods()).toHaveLength(1); + expect(jitState.getAuthMethods()[0].id).toBe("email"); + + // Challenge the email authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("us**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit verification code + const submitResult = await verificationState.submitChallenge("123456"); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(submitResult.data?.getAccount()?.idToken).toStrictEqual( + TestServerTokenResponse.id_token + ); + }); + + it("should handle JIT registration with fast-pass scenario (same email as sign-up)", async () => { + // Setup basic signup flow + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT flow + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "test@test.com", // Same email as signup + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "preverified", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Complete SignUp flow + const startResult = await app.signUp(signUpInputs); + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + // SignIn after signup completion - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + // Verify JIT registration is required + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method (same as sign-up email) + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "test@test.com", // Same email as signup + }); + + // Fast-pass should complete immediately + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isCompleted()).toBe(true); + expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle JIT registration with custom claims after sign up", async () => { + // Setup basic signup flow + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // JIT flow with custom claims + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + ...TestServerTokenResponse, + id_token: + TestServerTokenResponse.id_token + "_custom_claims", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Complete SignUp flow + const startResult = await app.signUp(signUpInputs); + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + // SignIn with custom claims after signup completion + const customClaims = JSON.stringify({ custom: "claim_value" }); + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn({ + scopes: ["custom-scope"], + claims: customClaims, + }); + + // Complete JIT flow + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + const submitResult = await verificationState.submitChallenge("123456"); + + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data?.getAccount()?.idToken).toBe( + TestServerTokenResponse.id_token + "_custom_claims" + ); + }); + + it("should handle JIT registration error scenarios after sign up", async () => { + // Setup basic sign up flow mocks + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Mock error for incorrect challenge + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_request", + error_description: "The verification code is incorrect.", + suberror: "verification_code_incorrect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Complete SignUp flow + const startResult = await app.signUp(signUpInputs); + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + // SignIn after signup - triggers JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Submit incorrect verification code + const submitResult = await verificationState.submitChallenge( + "wrong-code" + ); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeDefined(); + expect(submitResult.isFailed()).toBe(true); + expect(submitResult.error?.isIncorrectChallenge()).toBe(true); + }); + + it("should handle resending JIT verification code after sign up", async () => { + // Setup basic sign up flow mocks + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Initial challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Resend challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-resend-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Complete SignUp flow + const startResult = await app.signUp(signUpInputs); + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + // SignIn after signup - triggers JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Resend the challenge + const resendResult = await verificationState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(resendResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(resendResult.error).toBeUndefined(); + expect(resendResult.isVerificationRequired()).toBe(true); + + const newVerificationState = + resendResult.state as AuthMethodVerificationRequiredState; + expect(newVerificationState.getChannel()).toBe("email"); + expect(newVerificationState.getSentTo()).toBe("us**@co***so.com"); + expect(newVerificationState.getCodeLength()).toBe(6); + }); + + it("should handle JIT registration with incorrect verification contact error after sign up", async () => { + // Setup sign up flow mocks + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Mock error for incorrect verification contact + .mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_request", + error_description: "The verification contact is incorrect.", + error_codes: [901001], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + // Complete SignUp flow + const startResult = await app.signUp(signUpInputs); + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + // SignIn after signup - triggers JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + + // Challenge with incorrect verification contact + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "invalid@email.com", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeDefined(); + expect(challengeResult.isFailed()).toBe(true); + expect(challengeResult.error?.isInvalidInput()).toBe(true); + }); }); diff --git a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts index 36e30279eb..64298d5bcc 100644 --- a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts @@ -7,6 +7,7 @@ import { ResetPasswordCodeRequiredState } from "../../../../../src/custom_auth/r import { ResetPasswordClient } from "../../../../../src/custom_auth/reset_password/interaction_client/ResetPasswordClient.js"; import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("ResetPasswordCodeRequiredState", () => { @@ -23,6 +24,12 @@ describe("ResetPasswordCodeRequiredState", () => { const mockSignInClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -37,6 +44,7 @@ describe("ResetPasswordCodeRequiredState", () => { config: mockConfig, resetPasswordClient: mockResetPasswordClient, signInClient: mockSignInClient, + jitClient: mockJitClient, cacheClient: {} as unknown as jest.Mocked, username: username, diff --git a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts index 29f2599229..4f15e29a3a 100644 --- a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts @@ -7,6 +7,7 @@ import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; import { ResetPasswordPasswordRequiredState } from "../../../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; import { CustomAuthApiError } from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("ResetPasswordPasswordRequiredState", () => { @@ -22,6 +23,12 @@ describe("ResetPasswordPasswordRequiredState", () => { const mockSignInClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -36,6 +43,7 @@ describe("ResetPasswordPasswordRequiredState", () => { config: mockConfig, resetPasswordClient: mockResetPasswordClient, signInClient: mockSignInClient, + jitClient: mockJitClient, cacheClient: {} as unknown as jest.Mocked, username: username, diff --git a/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts index e34e7a5b25..ad9a6f8498 100644 --- a/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts +++ b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts @@ -53,11 +53,18 @@ jest.mock( submitNewPassword: jest.fn(), pollCompletion: jest.fn(), }; + let registerApiClient = { + introspect: jest.fn(), + challenge: jest.fn(), + continueWithOob: jest.fn(), + continueWithContinuationToken: jest.fn(), + }; const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ signInApi: signInApiClient, signUpApi: signUpApiClient, resetPasswordApi: resetPasswordApiClient, + registerApi: registerApiClient, })); const mockedApiClient = new CustomAuthApiClient(); @@ -66,6 +73,7 @@ jest.mock( signInApiClient, signUpApiClient, resetPasswordApiClient, + registerApiClient, }; } ); diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts index 3d862a32e0..6058836cba 100644 --- a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts @@ -15,6 +15,7 @@ import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; import { SignInCodeRequiredState } from "../../../../../src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.js"; import { DefaultCustomAuthApiCodeLength } from "../../../../../src/custom_auth/CustomAuthConstants.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInCodeRequiredState", () => { @@ -31,6 +32,12 @@ describe("SignInCodeRequiredState", () => { const mockCacheClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -42,6 +49,7 @@ describe("SignInCodeRequiredState", () => { username: username, signInClient: mockSignInClient, cacheClient: mockCacheClient, + jitClient: mockJitClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts index ba910c3d6f..82f5dd67ac 100644 --- a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts @@ -7,6 +7,7 @@ import { createSignInCompleteResult } from "../../../../../src/custom_auth/sign_ import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { SignInScenario } from "../../../../../src/custom_auth/sign_in/auth_flow/SignInScenario.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInContinuationState", () => { @@ -22,6 +23,12 @@ describe("SignInContinuationState", () => { const mockCacheClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -33,6 +40,7 @@ describe("SignInContinuationState", () => { username: username, signInClient: mockSignInClient, cacheClient: mockCacheClient, + jitClient: mockJitClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -91,6 +99,42 @@ describe("SignInContinuationState", () => { }); }); + it("should handle JIT required scenario during continuation token sign-in", async () => { + const jitContinuationToken = "jit-continuation-token"; + + mockSignInClient.signInWithContinuationToken.mockResolvedValue({ + type: "SignInJitRequiredResult", + continuationToken: jitContinuationToken, + correlationId: correlationId, + authMethods: ["email", "sms"], + } as any); + + const result = await state.signIn({ scopes: ["scope1", "scope2"] }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe( + "AuthMethodRegistrationRequiredState" + ); + }); + + it("should handle unexpected result type during continuation token sign-in", async () => { + mockSignInClient.signInWithContinuationToken.mockResolvedValue({ + type: "unexpected_result_type", + correlationId: correlationId, + } as any); + + const result = await state.signIn({ scopes: ["scope1", "scope2"] }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + }); + it("should return an error result if signIn throws an error", async () => { const mockError = new Error("Sign in failed"); mockSignInClient.signInWithContinuationToken.mockRejectedValue( diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts index 43cecc8334..83670ce29b 100644 --- a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts @@ -7,6 +7,7 @@ import { SignInPasswordRequiredState } from "../../../../../src/custom_auth/sign import { createSignInCompleteResult } from "../../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInPasswordRequiredState", () => { @@ -22,6 +23,12 @@ describe("SignInPasswordRequiredState", () => { const mockCacheClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -33,6 +40,7 @@ describe("SignInPasswordRequiredState", () => { username: username, signInClient: mockSignInClient, cacheClient: mockCacheClient, + jitClient: mockJitClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -110,4 +118,222 @@ describe("SignInPasswordRequiredState", () => { expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); }); + + it("should handle JIT required scenario after password submission", async () => { + const jitContinuationToken = "jit-continuation-token"; + + mockSignInClient.submitPassword.mockResolvedValue({ + type: "SignInJitRequiredResult", + continuationToken: jitContinuationToken, + correlationId: correlationId, + authMethods: ["email", "sms"], + } as any); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isFailed()).toBe(false); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.error).toBeUndefined(); + // Verify JIT state is returned + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe( + "AuthMethodRegistrationRequiredState" + ); + }); + + it("should handle unexpected result type from submitPassword", async () => { + mockSignInClient.submitPassword.mockResolvedValue({ + type: "unexpected_result_type", + correlationId: correlationId, + } as any); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + }); + + it("should return error for null password", async () => { + const result = await state.submitPassword(null as any); + + 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 return error for undefined password", async () => { + const result = await state.submitPassword(undefined as any); + + 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 allow whitespace-only password (matches current implementation)", async () => { + // Note: Current implementation doesn't validate whitespace, only falsy values + mockSignInClient.submitPassword.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + 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(" "); + + expect(result.isCompleted()).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should handle network timeout error", async () => { + const timeoutError = new Error("Network timeout"); + timeoutError.name = "TimeoutError"; + mockSignInClient.submitPassword.mockRejectedValue(timeoutError); + + const result = await state.submitPassword("valid-password"); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + }); + + it("should handle API rate limiting error", async () => { + const rateLimitError = new Error("Too many requests"); + rateLimitError.name = "RateLimitError"; + mockSignInClient.submitPassword.mockRejectedValue(rateLimitError); + + const result = await state.submitPassword("valid-password"); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + }); + + it("should properly call submitPassword with all required parameters", async () => { + mockSignInClient.submitPassword.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + 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", + }, + }) + ); + + await state.submitPassword("test-password"); + + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + password: "test-password", + username: username, + }); + }); + + it("should return correct scopes from getScopes method", () => { + const scopes = state.getScopes(); + expect(scopes).toEqual(["scope1", "scope2"]); + }); + + it("should handle case when scopes are undefined", () => { + const stateWithoutScopes = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + scopes: undefined, + }); + + const scopes = stateWithoutScopes.getScopes(); + expect(scopes).toBeUndefined(); + }); + + it("should handle submitPassword with complex authentication result", async () => { + const complexAuthResult = { + accessToken: "complex-access-token", + idToken: "complex-id-token", + expiresOn: new Date(Date.now() + 7200 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://complex-authority.com", + tenantId: "complex-tenant-id", + scopes: ["complex-scope1", "complex-scope2"], + account: { + homeAccountId: "complex-home-account-id", + environment: "complex-environment", + tenantId: "complex-tenant-id", + username: "complex-username", + localAccountId: "complex-local-account-id", + idToken: "complex-id-token", + }, + idTokenClaims: { + sub: "complex-subject", + aud: "complex-audience", + iss: "complex-issuer", + }, + fromCache: false, + uniqueId: "complex-unique-id", + }; + + mockSignInClient.submitPassword.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: complexAuthResult, + }) + ); + + const result = await state.submitPassword("complex-password"); + + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(result.data?.getAccount()).toBeDefined(); + }); }); diff --git a/lib/msal-browser/test/custom_auth/sign_in/interaction_client/SignInClient.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/interaction_client/SignInClient.spec.ts new file mode 100644 index 0000000000..450bfb15b1 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/interaction_client/SignInClient.spec.ts @@ -0,0 +1,828 @@ +import { SignInClient } from "../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../../src/custom_auth/CustomAuthConstants.js"; +import { + SIGN_IN_CODE_SEND_RESULT_TYPE, + SIGN_IN_COMPLETED_RESULT_TYPE, + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, + SIGN_IN_JIT_REQUIRED_RESULT_TYPE, + SignInCodeSendResult, + SignInJitRequiredResult, + SignInCompletedResult, +} from "../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInScenario } from "../../../../src/custom_auth/sign_in/auth_flow/SignInScenario.js"; +import { StubbedNetworkModule } from "@azure/msal-common/browser"; +import { buildConfiguration } from "../../../../src/config/Configuration.js"; +import { + getDefaultBrowserCacheManager, + getDefaultCrypto, + getDefaultEventHandler, + getDefaultLogger, + getDefaultNavigationClient, + getDefaultPerformanceClient, +} from "../../test_resources/TestModules.js"; +import { + TestServerTokenResponse, + TestTenantId, +} from "../../test_resources/TestConstants.js"; +import { CustomAuthApiError } from "../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import { REGISTRATION_REQUIRED } from "../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.js"; +import * as CustomAuthApiErrorCode from "../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import { + SignInContinuationTokenParams, + SignInSubmitCodeParams, + SignInSubmitPasswordParams, +} from "../../../../src/custom_auth/sign_in/interaction_client/parameter/SignInParams.js"; + +jest.mock( + "../../../../src/custom_auth/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(), + }; + let registerApiClient = { + introspect: jest.fn(), + challenge: jest.fn(), + continueWithOob: jest.fn(), + continueWithContinuationToken: jest.fn(), + }; + + // Set up the prototype or instance methods/properties + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + registerApi: registerApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + registerApiClient, + }; + } +); + +describe("SignInClient", () => { + let client: SignInClient; + let authority: CustomAuthAuthority; + const { mockedApiClient, signInApiClient, registerApiClient } = + jest.requireMock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); + + beforeEach(() => { + const clientId = customAuthConfig.auth.clientId; + const mockBrowserConfiguration = buildConfiguration( + { auth: { clientId: clientId } }, + false + ); + const mockLogger = getDefaultLogger(); + const mockPerformanceClient = getDefaultPerformanceClient(clientId); + const mockEventHandler = getDefaultEventHandler(); + const mockCrypto = getDefaultCrypto( + clientId, + mockLogger, + mockPerformanceClient + ); + const mockCacheManager = getDefaultBrowserCacheManager( + clientId, + mockLogger, + mockPerformanceClient, + mockEventHandler, + undefined, + mockBrowserConfiguration.cache + ); + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockBrowserConfiguration, + StubbedNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + client = new SignInClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + getDefaultNavigationClient(), + mockPerformanceClient, + mockedApiClient, + authority + ); + }); + + 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.type === SIGN_IN_CODE_SEND_RESULT_TYPE).toBeTruthy(); + + 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.type).toStrictEqual( + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should throw error for unsupported challenge type", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: "unsupported_type", + 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.toThrow( + expect.objectContaining({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + }) + ); + }); + }); + + describe("submitCode", () => { + let signInSubmitCodeParams: SignInSubmitCodeParams; + + beforeEach(() => { + signInApiClient.requestTokensWithOob.mockResolvedValue( + TestServerTokenResponse + ); + + signInSubmitCodeParams = { + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }; + }); + + it("should return SignInCompleteResult for valid code", async () => { + const result = await client.submitCode(signInSubmitCodeParams); + + expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe( + TestServerTokenResponse.correlation_id + ); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe( + TestServerTokenResponse.access_token + ); + expect(result.authenticationResult.idToken).toBe( + TestServerTokenResponse.id_token + ); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe( + TestServerTokenResponse.token_type + ); + expect(result.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(result.authenticationResult.tenantId).toBe(TestTenantId); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe( + "abc@test.com" + ); + }); + + it("should include claims in password token request", async () => { + const claims = JSON.stringify({ + access_token: { + acrs: { + essential: true, + value: "c1", + }, + }, + }); + + signInSubmitCodeParams.claims = claims; + await client.submitCode(signInSubmitCodeParams); + + // Verify that the API was called with claims + expect(signInApiClient.requestTokensWithOob).toHaveBeenCalledWith( + expect.objectContaining({ + claims: claims, + }) + ); + }); + + it("should throw JIT error instead of returning JIT result (since submitCode doesn't support JIT)", async () => { + const jitError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "JIT registration is required", + "corr123" + ); + jitError.subError = REGISTRATION_REQUIRED; + jitError.continuationToken = "jit_continuation_token"; + + signInApiClient.requestTokensWithOob.mockRejectedValue(jitError); + + await expect( + client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }) + ).rejects.toThrow(jitError); + }); + + it("should throw error for any other error", async () => { + const genericError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid code", + "corr123" + ); + + signInApiClient.requestTokensWithOob.mockRejectedValue( + genericError + ); + + await expect( + client.submitCode({ + code: "invalid", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }) + ).rejects.toThrow(genericError); + }); + }); + + describe("submitPassword", () => { + let signInSubmitPasswordParams: SignInSubmitPasswordParams; + + beforeEach(() => { + signInApiClient.requestTokensWithPassword.mockResolvedValue( + TestServerTokenResponse + ); + + signInSubmitPasswordParams = { + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }; + }); + + it("should return SignInCompleteResult for valid password", async () => { + const result = await client.submitPassword( + signInSubmitPasswordParams + ); + + expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe( + TestServerTokenResponse.correlation_id + ); + + const completedResult = result as SignInCompletedResult; + expect(completedResult.authenticationResult).toBeDefined(); + expect(completedResult.authenticationResult.accessToken).toBe( + TestServerTokenResponse.access_token + ); + expect(completedResult.authenticationResult.idToken).toBe( + TestServerTokenResponse.id_token + ); + expect( + completedResult.authenticationResult.expiresOn + ).toBeDefined(); + expect(completedResult.authenticationResult.tokenType).toBe( + TestServerTokenResponse.token_type + ); + expect(completedResult.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(completedResult.authenticationResult.tenantId).toBe( + TestTenantId + ); + expect(completedResult.authenticationResult.account).toBeDefined(); + expect(completedResult.authenticationResult.account.username).toBe( + "abc@test.com" + ); + }); + + it("should include claims in password token request", async () => { + const claims = JSON.stringify({ + access_token: { + acrs: { + essential: true, + value: "c1", + }, + }, + }); + + signInSubmitPasswordParams.claims = claims; + await client.submitPassword(signInSubmitPasswordParams); + + // Verify that the API was called with claims + expect( + signInApiClient.requestTokensWithPassword + ).toHaveBeenCalledWith( + expect.objectContaining({ + claims: claims, + }) + ); + }); + + it("should throw error when non-CustomAuthApiError occurs", async () => { + const genericError = new Error("Network error"); + + signInApiClient.requestTokensWithPassword.mockRejectedValue( + genericError + ); + + await expect( + client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }) + ).rejects.toThrow(genericError); + }); + + it("should return SignInJitRequiredResult when REGISTRATION_REQUIRED error occurs", async () => { + const jitError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "JIT registration is required", + "corr123" + ); + jitError.subError = REGISTRATION_REQUIRED; + jitError.continuationToken = "jit_continuation_token"; + + const mockIntrospectResponse = { + correlation_id: "corr123", + continuation_token: "introspect_continuation_token", + methods: [ + { id: "email", type: "email" }, + { id: "phone", type: "phone" }, + ], + }; + + signInApiClient.requestTokensWithPassword.mockRejectedValue( + jitError + ); + registerApiClient.introspect.mockResolvedValue( + mockIntrospectResponse + ); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.PASSWORD], + correlationId: "corr123", + scopes: [], + }); + + expect(result.type).toStrictEqual(SIGN_IN_JIT_REQUIRED_RESULT_TYPE); + expect(result.correlationId).toBe("corr123"); + + if (result.type === SIGN_IN_JIT_REQUIRED_RESULT_TYPE) { + const jitResult = result as SignInJitRequiredResult; + expect(jitResult.continuationToken).toBe( + "introspect_continuation_token" + ); + expect(jitResult.authMethods).toEqual( + mockIntrospectResponse.methods + ); + } + + // Verify introspect was called with correct parameters + expect(registerApiClient.introspect).toHaveBeenCalledWith({ + continuation_token: "jit_continuation_token", + correlationId: "corr123", + telemetryManager: expect.any(Object), + }); + }); + + it("should throw error when introspect call fails during JIT registration", async () => { + const jitError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "JIT registration is required", + "corr123" + ); + jitError.subError = REGISTRATION_REQUIRED; + jitError.continuationToken = "jit_continuation_token"; + + const introspectError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Introspect call failed", + "corr123" + ); + + signInApiClient.requestTokensWithPassword.mockRejectedValue( + jitError + ); + registerApiClient.introspect.mockRejectedValue(introspectError); + + await expect( + client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ChallengeType.PASSWORD], + correlationId: "corr123", + scopes: [], + }) + ).rejects.toThrow(introspectError); + }); + }); + + 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.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"); + }); + + it("should throw error when challenge type is PASSWORD", async () => { + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + await expect( + client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toThrow( + expect.objectContaining({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + }) + ); + }); + }); + + describe("signInWithContinuationToken", () => { + let signInContinuationTokenParams: SignInContinuationTokenParams; + + beforeEach(() => { + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + TestServerTokenResponse + ); + + signInContinuationTokenParams = { + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + signInScenario: SignInScenario.SignInAfterSignUp, + }; + }); + + it("should return SignInCompleteResult", async () => { + const result = await client.signInWithContinuationToken( + signInContinuationTokenParams + ); + + expect(result.correlationId).toBe( + TestServerTokenResponse.correlation_id + ); + expect(result.type).toBe(SIGN_IN_COMPLETED_RESULT_TYPE); + + if (result.type === SIGN_IN_COMPLETED_RESULT_TYPE) { + const completedResult = result as SignInCompletedResult; + expect(completedResult.authenticationResult).toBeDefined(); + expect(completedResult.authenticationResult.accessToken).toBe( + TestServerTokenResponse.access_token + ); + expect(completedResult.authenticationResult.idToken).toBe( + TestServerTokenResponse.id_token + ); + expect( + completedResult.authenticationResult.expiresOn + ).toBeDefined(); + expect(completedResult.authenticationResult.tokenType).toBe( + TestServerTokenResponse.token_type + ); + expect(completedResult.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(completedResult.authenticationResult.tenantId).toBe( + TestTenantId + ); + expect( + completedResult.authenticationResult.account + ).toBeDefined(); + expect( + completedResult.authenticationResult.account.username + ).toBe("abc@test.com"); + } + }); + + it("should return SignInCompleteResult for SignInAfterPasswordReset scenario", async () => { + signInContinuationTokenParams.signInScenario = + SignInScenario.SignInAfterPasswordReset; + + const result = await client.signInWithContinuationToken( + signInContinuationTokenParams + ); + + expect(result.correlationId).toBe( + TestServerTokenResponse.correlation_id + ); + expect(result.type).toBe(SIGN_IN_COMPLETED_RESULT_TYPE); + }); + + it("should throw error for unsupported sign-in scenario", async () => { + signInContinuationTokenParams.signInScenario = + "unsupported_scenario" as any; + + await expect( + client.signInWithContinuationToken( + signInContinuationTokenParams + ) + ).rejects.toThrow( + expect.objectContaining({ + message: expect.stringContaining( + "Unsupported sign-in scenario" + ), + }) + ); + }); + + it("should include claims in password token request", async () => { + const claims = JSON.stringify({ + access_token: { + acrs: { + essential: true, + value: "c1", + }, + }, + }); + + signInContinuationTokenParams.claims = claims; + await client.signInWithContinuationToken( + signInContinuationTokenParams + ); + + // Verify that the API was called with claims + expect( + signInApiClient.requestTokenWithContinuationToken + ).toHaveBeenCalledWith( + expect.objectContaining({ + claims: claims, + }) + ); + }); + + it("should throw error for any other error", async () => { + const genericError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid continuation token", + "corr123" + ); + + signInApiClient.requestTokenWithContinuationToken.mockRejectedValue( + genericError + ); + + await expect( + client.signInWithContinuationToken({ + continuationToken: "invalid_token", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + signInScenario: SignInScenario.SignInAfterSignUp, + }) + ).rejects.toThrow(genericError); + }); + + it("should return SignInJitRequiredResult when REGISTRATION_REQUIRED error occurs", async () => { + const jitError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "JIT registration is required", + "corr123" + ); + jitError.subError = REGISTRATION_REQUIRED; + jitError.continuationToken = "jit_continuation_token"; + + const mockIntrospectResponse = { + correlation_id: "introspect_corr_id", + continuation_token: "introspect_continuation_token", + methods: [ + { id: "email", type: "email" }, + { id: "phone", type: "phone" }, + ], + }; + + signInApiClient.requestTokenWithContinuationToken.mockRejectedValue( + jitError + ); + registerApiClient.introspect.mockResolvedValue( + mockIntrospectResponse + ); + + const result = await client.signInWithContinuationToken({ + continuationToken: "continuation_token_1", + username: "abc@test.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + signInScenario: SignInScenario.SignInAfterSignUp, + }); + + expect(result.type).toStrictEqual(SIGN_IN_JIT_REQUIRED_RESULT_TYPE); + expect(result.correlationId).toBe("introspect_corr_id"); + + if (result.type === SIGN_IN_JIT_REQUIRED_RESULT_TYPE) { + const jitResult = result as SignInJitRequiredResult; + expect(jitResult.continuationToken).toBe( + "introspect_continuation_token" + ); + expect(jitResult.authMethods).toEqual( + mockIntrospectResponse.methods + ); + } + + // Verify introspect was called with correct parameters + expect(registerApiClient.introspect).toHaveBeenCalledWith({ + continuation_token: "jit_continuation_token", + correlationId: "corr123", + telemetryManager: expect.any(Object), + }); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts deleted file mode 100644 index 3e21b4d57a..0000000000 --- a/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { SignInClient } from "../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; -import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; -import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; -import { ChallengeType } from "../../../../src/custom_auth/CustomAuthConstants.js"; -import { - SIGN_IN_CODE_SEND_RESULT_TYPE, - SIGN_IN_COMPLETED_RESULT_TYPE, - SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, - SignInCodeSendResult, -} from "../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; -import { SignInScenario } from "../../../../src/custom_auth/sign_in/auth_flow/SignInScenario.js"; -import { StubbedNetworkModule } from "@azure/msal-common/browser"; -import { buildConfiguration } from "../../../../src/config/Configuration.js"; -import { - getDefaultBrowserCacheManager, - getDefaultCrypto, - getDefaultEventHandler, - getDefaultLogger, - getDefaultNavigationClient, - getDefaultPerformanceClient, -} from "../../test_resources/TestModules.js"; -import { - TestServerTokenResponse, - TestTenantId, -} from "../../test_resources/TestConstants.js"; -import { - SignInContinuationTokenParams, - SignInSubmitCodeParams, - SignInSubmitPasswordParams, -} from "../../../../src/custom_auth/sign_in/interaction_client/parameter/SignInParams.js"; - -jest.mock( - "../../../../src/custom_auth/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 } = jest.requireMock( - "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" - ); - - beforeEach(() => { - const clientId = customAuthConfig.auth.clientId; - const mockBrowserConfiguration = buildConfiguration( - { auth: { clientId: clientId } }, - false - ); - const mockLogger = getDefaultLogger(); - const mockPerformanceClient = getDefaultPerformanceClient(clientId); - const mockEventHandler = getDefaultEventHandler(); - const mockCrypto = getDefaultCrypto( - clientId, - mockLogger, - mockPerformanceClient - ); - const mockCacheManager = getDefaultBrowserCacheManager( - clientId, - mockLogger, - mockPerformanceClient, - mockEventHandler, - undefined, - mockBrowserConfiguration.cache - ); - - authority = new CustomAuthAuthority( - customAuthConfig.auth.authority ?? "", - mockBrowserConfiguration, - StubbedNetworkModule, - mockCacheManager, - mockLogger, - customAuthConfig.customAuth.authApiProxyUrl - ); - - client = new SignInClient( - mockBrowserConfiguration, - mockCacheManager, - mockCrypto, - mockLogger, - mockEventHandler, - getDefaultNavigationClient(), - mockPerformanceClient, - mockedApiClient, - authority - ); - }); - - 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.type === SIGN_IN_CODE_SEND_RESULT_TYPE).toBeTruthy(); - - 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.type).toStrictEqual( - SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE - ); - expect(result.correlationId).toBe("corr123"); - expect(result.continuationToken).toBe("continuation_token_2"); - }); - }); - - describe("submitCode", () => { - let signInSubmitCodeParams: SignInSubmitCodeParams; - - beforeEach(() => { - signInApiClient.requestTokensWithOob.mockResolvedValue( - TestServerTokenResponse - ); - - signInSubmitCodeParams = { - code: "123456", - continuationToken: "continuation_token_1", - username: "abc@test.com", - clientId: customAuthConfig.auth.clientId, - challengeType: [ - ChallengeType.OOB, - ChallengeType.PASSWORD, - ChallengeType.REDIRECT, - ], - correlationId: "corr123", - scopes: [], - }; - }); - - it("should return SignInCompleteResult for valid code", async () => { - const result = await client.submitCode(signInSubmitCodeParams); - - expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); - expect(result.correlationId).toBe( - TestServerTokenResponse.correlation_id - ); - expect(result.authenticationResult).toBeDefined(); - expect(result.authenticationResult.accessToken).toBe( - TestServerTokenResponse.access_token - ); - expect(result.authenticationResult.idToken).toBe( - TestServerTokenResponse.id_token - ); - expect(result.authenticationResult.expiresOn).toBeDefined(); - expect(result.authenticationResult.tokenType).toBe( - TestServerTokenResponse.token_type - ); - expect(result.authenticationResult.authority).toBe( - authority.canonicalAuthority - ); - expect(result.authenticationResult.tenantId).toBe(TestTenantId); - expect(result.authenticationResult.account).toBeDefined(); - expect(result.authenticationResult.account.username).toBe( - "abc@test.com" - ); - }); - - it("should include claims in password token request", async () => { - const claims = JSON.stringify({ - access_token: { - acrs: { - essential: true, - value: "c1", - }, - }, - }); - - signInSubmitCodeParams.claims = claims; - await client.submitCode(signInSubmitCodeParams); - - // Verify that the API was called with claims - expect(signInApiClient.requestTokensWithOob).toHaveBeenCalledWith( - expect.objectContaining({ - claims: claims, - }) - ); - }); - }); - - describe("submitPassword", () => { - let signInSubmitPasswordParams: SignInSubmitPasswordParams; - - beforeEach(() => { - signInApiClient.requestTokensWithPassword.mockResolvedValue( - TestServerTokenResponse - ); - - signInSubmitPasswordParams = { - password: "123456", - continuationToken: "continuation_token_1", - username: "abc@test.com", - clientId: customAuthConfig.auth.clientId, - challengeType: [ - ChallengeType.OOB, - ChallengeType.PASSWORD, - ChallengeType.REDIRECT, - ], - correlationId: "corr123", - scopes: [], - }; - }); - - it("should return SignInCompleteResult for valid password", async () => { - const result = await client.submitPassword( - signInSubmitPasswordParams - ); - - expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); - expect(result.correlationId).toBe( - TestServerTokenResponse.correlation_id - ); - expect(result.authenticationResult).toBeDefined(); - expect(result.authenticationResult.accessToken).toBe( - TestServerTokenResponse.access_token - ); - expect(result.authenticationResult.idToken).toBe( - TestServerTokenResponse.id_token - ); - expect(result.authenticationResult.expiresOn).toBeDefined(); - expect(result.authenticationResult.tokenType).toBe( - TestServerTokenResponse.token_type - ); - expect(result.authenticationResult.authority).toBe( - authority.canonicalAuthority - ); - expect(result.authenticationResult.tenantId).toBe(TestTenantId); - expect(result.authenticationResult.account).toBeDefined(); - expect(result.authenticationResult.account.username).toBe( - "abc@test.com" - ); - }); - - it("should include claims in password token request", async () => { - const claims = JSON.stringify({ - access_token: { - acrs: { - essential: true, - value: "c1", - }, - }, - }); - - signInSubmitPasswordParams.claims = claims; - await client.submitPassword(signInSubmitPasswordParams); - - // Verify that the API was called with claims - expect( - signInApiClient.requestTokensWithPassword - ).toHaveBeenCalledWith( - expect.objectContaining({ - claims: claims, - }) - ); - }); - }); - - 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.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", () => { - let signInContinuationTokenParams: SignInContinuationTokenParams; - - beforeEach(() => { - signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( - TestServerTokenResponse - ); - - signInContinuationTokenParams = { - continuationToken: "continuation_token_1", - username: "abc@test.com", - clientId: customAuthConfig.auth.clientId, - challengeType: [ - ChallengeType.OOB, - ChallengeType.PASSWORD, - ChallengeType.REDIRECT, - ], - correlationId: "corr123", - scopes: [], - signInScenario: SignInScenario.SignInAfterSignUp, - }; - }); - - it("should return SignInCompleteResult", async () => { - const result = await client.signInWithContinuationToken( - signInContinuationTokenParams - ); - - expect(result.correlationId).toBe( - TestServerTokenResponse.correlation_id - ); - expect(result.authenticationResult).toBeDefined(); - expect(result.authenticationResult.accessToken).toBe( - TestServerTokenResponse.access_token - ); - expect(result.authenticationResult.idToken).toBe( - TestServerTokenResponse.id_token - ); - expect(result.authenticationResult.expiresOn).toBeDefined(); - expect(result.authenticationResult.tokenType).toBe( - TestServerTokenResponse.token_type - ); - expect(result.authenticationResult.authority).toBe( - authority.canonicalAuthority - ); - expect(result.authenticationResult.tenantId).toBe(TestTenantId); - expect(result.authenticationResult.account).toBeDefined(); - expect(result.authenticationResult.account.username).toBe( - "abc@test.com" - ); - }); - - it("should include claims in password token request", async () => { - const claims = JSON.stringify({ - access_token: { - acrs: { - essential: true, - value: "c1", - }, - }, - }); - - signInContinuationTokenParams.claims = claims; - await client.signInWithContinuationToken( - signInContinuationTokenParams - ); - - // Verify that the API was called with claims - expect( - signInApiClient.requestTokenWithContinuationToken - ).toHaveBeenCalledWith( - expect.objectContaining({ - claims: claims, - }) - ); - }); - }); -}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts index c20049044e..d322429cc3 100644 --- a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts @@ -7,6 +7,7 @@ import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { UserAccountAttributes } from "../../../../../src/custom_auth/UserAccountAttributes.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpAttributesRequiredState", () => { @@ -21,6 +22,12 @@ describe("SignUpAttributesRequiredState", () => { const mockSignInClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -35,6 +42,7 @@ describe("SignUpAttributesRequiredState", () => { username: username, signUpClient: mockSignUpClient, signInClient: mockSignInClient, + jitClient: mockJitClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId, diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts index 3d2baa65fd..48d10829f4 100644 --- a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts @@ -13,6 +13,7 @@ import { import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpCodeRequiredState", () => { @@ -28,6 +29,12 @@ describe("SignUpCodeRequiredState", () => { const mockSignInClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -39,6 +46,7 @@ describe("SignUpCodeRequiredState", () => { username: username, signUpClient: mockSignUpClient, signInClient: mockSignInClient, + jitClient: mockJitClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId, diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts index d93712fedf..167f51b080 100644 --- a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts @@ -10,6 +10,7 @@ import { import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpPasswordRequiredState", () => { @@ -24,6 +25,12 @@ describe("SignUpPasswordRequiredState", () => { const mockSignInClient = {} as unknown as jest.Mocked; + const mockJitClient = { + introspect: jest.fn(), + requestChallenge: jest.fn(), + continueChallenge: jest.fn(), + } as unknown as jest.Mocked; + const username = "testuser"; const correlationId = "test-correlation-id"; const continuationToken = "test-continuation-token"; @@ -35,6 +42,7 @@ describe("SignUpPasswordRequiredState", () => { username: username, signUpClient: mockSignUpClient, signInClient: mockSignInClient, + jitClient: mockJitClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId, diff --git a/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts index 34f010e72c..84e4315d67 100644 --- a/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts +++ b/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts @@ -46,11 +46,18 @@ jest.mock( submitNewPassword: jest.fn(), pollCompletion: jest.fn(), }; + let registerApiClient = { + introspect: jest.fn(), + challenge: jest.fn(), + continueWithOob: jest.fn(), + continueWithContinuationToken: jest.fn(), + }; const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ signInApi: signInApiClient, signUpApi: signUpApiClient, resetPasswordApi: resetPasswordApiClient, + registerApi: registerApiClient, })); const mockedApiClient = new CustomAuthApiClient();