diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..d9620ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,39 @@ +name: Bug report +description: Create a report to help us improve +title: '[Bug]: ' + +body: + - type: input + id: version + attributes: + label: Version + validations: + required: true + + - type: input + id: reproduction + attributes: + label: Please provide a link to a minimal reproduction of the bug + + - type: textarea + id: exception-or-error + attributes: + label: Please provide the exception or error you saw + render: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce the behavior + render: true + + - type: textarea + id: expectation + attributes: + label: A clear and concise description of what you expected to happen. + render: true + + - type: textarea + id: other + attributes: + label: Additional context diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..217eb84 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[Feature Request]: ' +labels: '' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..990fd55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,13 @@ +--- +name: Question +about: Ask a question using this lib like 'How can I ...' +title: '[Question]: ' +labels: ['question'] +assignees: '' +--- + +**What Version of the library are you using?** +... + +**Question** +How can I ... diff --git a/.github/ISSUE_TEMPLATE/refactoring.md b/.github/ISSUE_TEMPLATE/refactoring.md new file mode 100644 index 0000000..de605c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactoring.md @@ -0,0 +1,7 @@ +--- +name: Code refactoring +about: Any code improvements which make the code better +title: '[Refactoring]: ' +assignees: 'FabianGosebrink' +labels: ['refactoring'] +--- diff --git a/.github/angular-auth-logo.png b/.github/angular-auth-logo.png new file mode 100644 index 0000000..47d081e Binary files /dev/null and b/.github/angular-auth-logo.png differ diff --git a/.github/angular-auth-oidc-client-schematics-720.gif b/.github/angular-auth-oidc-client-schematics-720.gif new file mode 100644 index 0000000..3e7cd70 Binary files /dev/null and b/.github/angular-auth-oidc-client-schematics-720.gif differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..3d08c6c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,265 @@ +name: Build, Lint & Test Lib + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + +jobs: + build_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Built, Lint and Test Library + steps: + - uses: actions/checkout@v2 + with: + submodules: true + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Installing Dependencies + run: npm ci + + - name: Linting Library + run: npm run lint-lib + + - name: Testing Frontend + run: npm run test-lib-ci + + - name: Coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: './coverage/angular-auth-oidc-client/lcov.info' + + - name: Coveralls Finished + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.github_token }} + parallel-finished: true + + - name: Building Frontend + run: npm run build-lib-prod + + - name: Copying essential additional files + run: npm run copy-files + + - name: Show files + run: ls + + - name: Upload Artefact + uses: actions/upload-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: dist/angular-auth-oidc-client + + AngularLatestVersion: + needs: build_job + runs-on: ubuntu-latest + name: Angular latest + steps: + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Download Artefact + uses: actions/download-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: angular-auth-oidc-client-artefact + + - name: Install AngularCLI globally + run: sudo npm install -g @angular/cli + + - name: Show ng Version + run: ng version + + - name: Create Angular Project + run: sudo ng new angular-auth-oidc-client-test --skip-git + + - name: Npm Install & Install Library from local artefact + run: | + sudo cp -R angular-auth-oidc-client-artefact angular-auth-oidc-client-test/ + cd angular-auth-oidc-client-test + sudo npm install --unsafe-perm=true + sudo ng add ./angular-auth-oidc-client-artefact --authority-url-or-tenant-id "my-authority-url" --flow-type "OIDC Code Flow PKCE using refresh tokens" --use-local-package=true --skip-confirmation + + - name: Test Angular Application + working-directory: ./angular-auth-oidc-client-test + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Build Angular Application + working-directory: ./angular-auth-oidc-client-test + run: sudo npm run build + + AngularLatestVersionWithSchematics: + needs: build_job + runs-on: ubuntu-latest + name: Angular latest & Schematics Job + steps: + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Download Artefact + uses: actions/download-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: angular-auth-oidc-client-artefact + + - name: Install AngularCLI globally + run: sudo npm install -g @angular/cli + + - name: Show ng Version + run: ng version + + - name: Create Angular Project + run: sudo ng new angular-auth-oidc-client-test --skip-git + + - name: Npm Install & Install Library from local artefact + run: | + sudo cp -R angular-auth-oidc-client-artefact angular-auth-oidc-client-test/ + cd angular-auth-oidc-client-test + sudo npm install --unsafe-perm=true + sudo ng add ./angular-auth-oidc-client-artefact --authority-url-or-tenant-id "my-authority-url" --flow-type "Default config" --use-local-package=true --skip-confirmation + + - name: Test Angular Application + working-directory: ./angular-auth-oidc-client-test + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Build Angular Application + working-directory: ./angular-auth-oidc-client-test + run: sudo npm run build + + AngularLatestVersionWithNgModuleSchematics: + needs: build_job + runs-on: ubuntu-latest + name: Angular latest Standalone & Schematics Job + steps: + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Download Artefact + uses: actions/download-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: angular-auth-oidc-client-artefact + + - name: Install AngularCLI globally + run: sudo npm install -g @angular/cli + + - name: Show ng Version + run: ng version + + - name: Create Angular Project + run: sudo ng new angular-auth-oidc-client-test --skip-git --standalone=false + + - name: Npm Install & Install Library from local artefact + run: | + sudo cp -R angular-auth-oidc-client-artefact angular-auth-oidc-client-test/ + cd angular-auth-oidc-client-test + sudo npm install --unsafe-perm=true + sudo ng add ./angular-auth-oidc-client-artefact --authority-url-or-tenant-id "my-authority-url" --flow-type "OIDC Code Flow PKCE using refresh tokens" --use-local-package=true --skip-confirmation + + - name: Test Angular Application + working-directory: ./angular-auth-oidc-client-test + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Build Angular Application + working-directory: ./angular-auth-oidc-client-test + run: sudo npm run build + + Angular16VersionWithRxJs6: + needs: build_job + runs-on: ubuntu-latest + name: Angular 16 & RxJs 6 + steps: + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 20 + + - name: Download Artefact + uses: actions/download-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: angular-auth-oidc-client-artefact + + - name: Install AngularCLI globally + run: sudo npm install -g @angular/cli@16 + + - name: Show ng Version + run: ng version + + - name: Create Angular Project + run: sudo ng new angular-auth-oidc-client-test --skip-git + + - name: npm install RxJs 6 + working-directory: ./angular-auth-oidc-client-test + run: sudo npm install rxjs@6.5.3 + + - name: Npm Install & Install Library from local artefact + run: | + sudo cp -R angular-auth-oidc-client-artefact angular-auth-oidc-client-test/ + cd angular-auth-oidc-client-test + sudo npm install --unsafe-perm=true + sudo ng add ./angular-auth-oidc-client-artefact --authority-url-or-tenant-id "my-authority-url" --flow-type "OIDC Code Flow PKCE using refresh tokens" --use-local-package=true --skip-confirmation + + - name: Test Angular Application + working-directory: ./angular-auth-oidc-client-test + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Build Angular Application + working-directory: ./angular-auth-oidc-client-test + run: sudo npm run build + + LibWithAngularV16: + needs: build_job + runs-on: ubuntu-latest + name: Angular V16 + steps: + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 18 + + - name: Download Artefact + uses: actions/download-artifact@v3 + with: + name: angular_auth_oidc_client_artefact + path: angular-auth-oidc-client-artefact + + - name: Install AngularCLI globally + run: sudo npm install -g @angular/cli@16 + + - name: Show ng Version + run: ng version + + - name: Create Angular Project + run: sudo ng new angular-auth-oidc-client-test --skip-git + + - name: Npm Install & Install Library from local artefact + run: | + sudo cp -R angular-auth-oidc-client-artefact angular-auth-oidc-client-test/ + cd angular-auth-oidc-client-test + sudo npm install --unsafe-perm=true + sudo ng add ./angular-auth-oidc-client-artefact --authority-url-or-tenant-id "my-authority-url" --flow-type "OIDC Code Flow PKCE using refresh tokens" --use-local-package=true --skip-confirmation + + - name: Test Angular Application + working-directory: ./angular-auth-oidc-client-test + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Build Angular Application + working-directory: ./angular-auth-oidc-client-test + run: sudo npm run build diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..ca942a2 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,61 @@ +name: Docs + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Docs Job + steps: + - uses: actions/checkout@v2 + with: + submodules: true + + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: 18 + + - name: Installing Dependencies + run: sudo npm install + + - name: Installing Dependencies for docs - in docs folder + run: sudo npm install + working-directory: docs/site/angular-auth-oidc-client + + - name: Building Documentation + run: sudo npm run build + working-directory: docs/site/angular-auth-oidc-client + + - name: Build And Deploy + if: ${{ github.actor == 'damienbod' || github.actor == 'FabianGosebrink' }} + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: 'upload' + ###### Repository/Build Configurations - These values can be configured to match you app requirements. ###### + app_location: '/docs/site/angular-auth-oidc-client' # App source code path + app_artifact_location: 'build' # Built app content directory - optional + ###### End of Repository/Build Configurations ###### + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + action: 'close' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20529ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc +# Only exists if Bazel was run +/bazel-out + +# dependencies +node_modules + +# profiling files +chrome-profiler-events*.json +speed-measure-plugin*.json + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# misc +/.angular/cache +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +/.angulardoc.json +debug.log + +/.husky +/.nx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8a28fa4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + "npm.packageManager": "pnpm", + "rust-analyzer.showUnlinkedFileNotification": false, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "editor.codeActionsOnSave": { + "quickfix.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "emmet.showExpandedAbbreviation": "never", + "prettier.enable": false, + "tailwindCSS.experimental.configFile": "./packages/tailwind-config/config.ts", + "typescript.tsdk": "node_modules/typescript/lib", + "rust-analyzer.cargo.features": [ + "testcontainers" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcf2edd --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# Angular Lib for OpenID Connect & OAuth2 + +![Build Status](https://github.com/damienbod/angular-auth-oidc-client/actions/workflows/build.yml/badge.svg?branch=main) [![npm](https://img.shields.io/npm/v/angular-auth-oidc-client.svg)](https://www.npmjs.com/package/angular-auth-oidc-client) [![npm](https://img.shields.io/npm/dm/angular-auth-oidc-client.svg)](https://www.npmjs.com/package/angular-auth-oidc-client) [![npm](https://img.shields.io/npm/l/angular-auth-oidc-client.svg)](https://www.npmjs.com/package/angular-auth-oidc-client) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) [![Coverage Status](https://coveralls.io/repos/github/damienbod/angular-auth-oidc-client/badge.svg?branch=main)](https://coveralls.io/github/damienbod/angular-auth-oidc-client?branch=main) + +

+ +

+ +Secure your Angular app using the latest standards for OpenID Connect & OAuth2. Provides support for token refresh, all modern OIDC Identity Providers and more. + +## Acknowledgements + +This library is certified by OpenID Foundation. (RP Implicit and Config RP) + +

+ +

+ +## Features + +- [Code samples](https://angular-auth-oidc-client.com/docs/samples/) for most of the common use cases +- Supports schematics via `ng add` support +- Supports all modern OIDC identity providers +- Supports OpenID Connect Code Flow with PKCE +- Supports Code Flow PKCE with Refresh tokens +- [Supports OpenID Connect Implicit Flow](http://openid.net/specs/openid-connect-implicit-1_0.html) +- [Supports OpenID Connect Session Management 1.0](http://openid.net/specs/openid-connect-session-1_0.html) +- [Supports RFC7009 - OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009) +- [Supports RFC7636 - Proof Key for Code Exchange (PKCE)](https://tools.ietf.org/html/rfc7636) +- [Supports OAuth 2.0 Pushed authorisation requests (PAR) draft](https://tools.ietf.org/html/draft-ietf-oauth-par-06) +- Semantic releases +- Github actions +- Modern coding guidelines with prettier, husky +- Up to date documentation +- Implements OIDC validation as specified, complete client side validation for REQUIRED features +- Supports authentication using redirect or popup + +## Installation + +### Ng Add + +You can use the schematics and `ng add` the library. + +```shell +ng add angular-auth-oidc-client +``` + +And answer the questions. A module will be created which encapsulates your configuration. + +![angular-auth-oidc-client schematics](https://raw.githubusercontent.com/damienbod/angular-auth-oidc-client/main/.github/angular-auth-oidc-client-schematics-720.gif) + +### Npm / Yarn + +Navigate to the level of your `package.json` and type + +```shell + npm install angular-auth-oidc-client +``` + +or with yarn + +```shell + yarn add angular-auth-oidc-client +``` + +## Documentation + +[Read the docs here](https://angular-auth-oidc-client.com/) + +## Samples + +[Explore the Samples here](https://angular-auth-oidc-client.com/docs/samples/) + +## Quickstart + +For the example of the Code Flow. For further examples please check the [Samples](https://angular-auth-oidc-client.com/docs/samples/) Section. + +> If you have done the installation with the schematics, these modules and files should be available already! + +### Configuration + +Import the `AuthModule` in your module. + +```ts +import { NgModule } from '@angular/core'; +import { AuthModule, LogLevel } from 'angular-auth-oidc-client'; +// ... + +@NgModule({ + // ... + imports: [ + // ... + AuthModule.forRoot({ + config: { + authority: '', + redirectUrl: window.location.origin, + postLogoutRedirectUri: window.location.origin, + clientId: '', + scope: 'openid profile email offline_access', + responseType: 'code', + silentRenew: true, + useRefreshToken: true, + logLevel: LogLevel.Debug, + }, + }), + ], + // ... +}) +export class AppModule {} +``` + +And call the method `checkAuth()` from your `app.component.ts`. The method `checkAuth()` is needed to process the redirect from your Security Token Service and set the correct states. This method must be used to ensure the correct functioning of the library. + +```ts +import { Component, OnInit, inject } from '@angular/core'; +import { OidcSecurityService } from 'angular-auth-oidc-client'; + +@Component({ + /*...*/ +}) +export class AppComponent implements OnInit { + private readonly oidcSecurityService = inject(OidcSecurityService); + + ngOnInit() { + this.oidcSecurityService + .checkAuth() + .subscribe((loginResponse: LoginResponse) => { + const { isAuthenticated, userData, accessToken, idToken, configId } = + loginResponse; + + /*...*/ + }); + } + + login() { + this.oidcSecurityService.authorize(); + } + + logout() { + this.oidcSecurityService + .logoff() + .subscribe((result) => console.log(result)); + } +} +``` + +### Using the access token + +You can get the access token by calling the method `getAccessToken()` on the `OidcSecurityService` + +```ts +const token = this.oidcSecurityService.getAccessToken().subscribe(...); +``` + +And then you can use it in the HttpHeaders + +```ts +import { HttpHeaders } from '@angular/common/http'; + +const token = this.oidcSecurityServices.getAccessToken().subscribe((token) => { + const httpOptions = { + headers: new HttpHeaders({ + Authorization: 'Bearer ' + token, + }), + }; +}); +``` + +You can use the built in interceptor to add the accesstokens to your request + +```ts +AuthModule.forRoot({ + config: { + // ... + secureRoutes: ['https://my-secure-url.com/', 'https://my-second-secure-url.com/'], + }, +}), +``` + +```ts + providers: [ + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, + ], +``` + +## Versions + +Current Version is Version 19.x + +- [Info about Version 18](https://github.com/damienbod/angular-auth-oidc-client/tree/version-18) +- [Info about Version 17](https://github.com/damienbod/angular-auth-oidc-client/tree/version-17) +- [Info about Version 16](https://github.com/damienbod/angular-auth-oidc-client/tree/version-16) +- [Info about Version 15](https://github.com/damienbod/angular-auth-oidc-client/tree/version-15) +- [Info about Version 14](https://github.com/damienbod/angular-auth-oidc-client/tree/version-14) +- [Info about Version 13](https://github.com/damienbod/angular-auth-oidc-client/tree/version-13) +- [Info about Version 12](https://github.com/damienbod/angular-auth-oidc-client/tree/version-12) +- [Info about Version 11](https://github.com/damienbod/angular-auth-oidc-client/tree/version-11) +- [Info about Version 10](https://github.com/damienbod/angular-auth-oidc-client/tree/version-10) + +## License + +[MIT](https://choosealicense.com/licenses/mit/) + +## Authors + +- [@DamienBod](https://www.github.com/damienbod) +- [@FabianGosebrink](https://www.github.com/FabianGosebrink) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..170a920 --- /dev/null +++ b/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["ultracite"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off" + }, + "correctness": { + "noUnusedImports": { + "fix": "none", + "level": "warn" + } + } + } + }, + "files": { + "ignore": [ + ".vscode/*.json" + ] + } +} diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..05f83e2 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,50 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + jasmine: { + // you can add configuration options for Jasmine here + // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html + // for example, you can disable the random execution with `random: false` + // or set a specific seed with `seed: 4321` + }, + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + jasmineHtmlReporter: { + suppressAll: true, // removes the duplicated traces + }, + coverageReporter: { + dir: require('path').join( + __dirname, + '../../coverage/angular-auth-oidc-client' + ), + subdir: '.', + reporters: [{ type: 'html' }, { type: 'text-summary' }, { type: 'lcov' }], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox'], + }, + }, + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..cfcc968 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,41 @@ +# EXAMPLE USAGE +# Refer for explanation to following link: +# https://github.com/evilmartians/lefthook/blob/master/docs/full_guide.md +# +pre-push: + commands: + fix-prettier: + tags: frontend security + glob: '*.{js,ts}' + run: npm run fix-prettier {staged_files} + +pre-commit: + parallel: true + commands: + check-blockwords: + run: npm run check-blockwords + + lint: + run: npm run lint-lib +# +# pre-commit: +# parallel: true +# commands: +# eslint: +# glob: "*.{js,ts}" +# run: yarn eslint {staged_files} +# rubocop: +# tags: backend style +# glob: "*.rb" +# exclude: "application.rb|routes.rb" +# run: bundle exec rubocop --force-exclusion {all_files} +# govet: +# tags: backend style +# files: git ls-files -m +# glob: "*.go" +# run: go vet {files} +# scripts: +# "hello.js": +# runner: node +# "any.go": +# runner: go run diff --git a/license-banner.txt b/license-banner.txt new file mode 100644 index 0000000..62a24bb --- /dev/null +++ b/license-banner.txt @@ -0,0 +1,4 @@ +/** + * @license oidc-client-rx + * MIT license + */ diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc2240c --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "oidc-client-rx", + "homepage": "https://github.com/lonelyhentxi/oidc-client-rx", + "author": "lonelyhentxi", + "description": "ReactiveX enhanced OIDC and OAuth2 protocol support for browser-based JavaScript applications", + "repository": { + "type": "git", + "url": "https://github.com/lonelyhentxi/oidc-client-rx.git" + }, + "bugs": { + "url": "https://github.com/lonelyhentxi/oidc-client-rx/issues" + }, + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch", + "test-lib": "ng test angular-auth-oidc-client --code-coverage", + "test-lib-ci": "ng test angular-auth-oidc-client --watch=false --browsers=ChromeHeadlessNoSandbox --code-coverage", + "lint-lib": "ng lint angular-auth-oidc-client", + "pack-lib": "npm run build-lib-prod && npm pack ./dist/angular-auth-oidc-client", + "publish-lib": "npm run build-lib-prod && npm publish ./dist/angular-auth-oidc-client", + "publish-lib-next": "npm run build-lib && npm publish --tag next ./dist/angular-auth-oidc-client", + "coveralls": "cat ./coverage/lcov.info | coveralls", + "lint": "ultracite lint", + "format": "ultracite format" + }, + "dependencies": { + "@ngify/http": "^2.0.4", + "injection-js": "git+https://github.com/mgechev/injection-js.git#81a10e0", + "rxjs": ">=7.4.0", + "shelljs": "^0.8.5", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@evilmartians/lefthook": "^1.0.3", + "@rslib/core": "^0.3.1", + "@types/jasmine": "^4.0.0", + "@types/node": "^22.10.1", + "copyfiles": "^2.4.1", + "coveralls": "^3.1.0", + "fs-extra": "^10.0.0", + "jasmine-core": "^4.0.0", + "jasmine-spec-reporter": "~7.0.0", + "karma": "~6.4.2", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "^2.0.0", + "rfc4648": "^1.5.0", + "typescript": "^5.7.3" + }, + "keywords": [ + "rxjs", + "oidc", + "oauth2", + "openid", + "security", + "typescript", + "openidconnect", + "auth", + "authn", + "authentication", + "identity", + "certified", + "oauth", + "authorization", + "reactivex" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..3b9bf9a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2614 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ngify/http': + specifier: ^2.0.4 + version: 2.0.4(rxjs@7.8.1) + injection-js: + specifier: git+https://github.com/mgechev/injection-js.git#81a10e0 + version: https://codeload.github.com/mgechev/injection-js/tar.gz/81a10e0 + rxjs: + specifier: '>=7.4.0' + version: 7.8.1 + shelljs: + specifier: ^0.8.5 + version: 0.8.5 + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + '@evilmartians/lefthook': + specifier: ^1.0.3 + version: 1.10.7 + '@rslib/core': + specifier: ^0.3.1 + version: 0.3.1(typescript@5.7.3) + '@types/jasmine': + specifier: ^4.0.0 + version: 4.6.4 + '@types/node': + specifier: ^22.10.1 + version: 22.10.7 + copyfiles: + specifier: ^2.4.1 + version: 2.4.1 + coveralls: + specifier: ^3.1.0 + version: 3.1.1 + fs-extra: + specifier: ^10.0.0 + version: 10.1.0 + jasmine-core: + specifier: ^4.0.0 + version: 4.6.1 + jasmine-spec-reporter: + specifier: ~7.0.0 + version: 7.0.0 + karma: + specifier: ~6.4.2 + version: 6.4.4 + karma-chrome-launcher: + specifier: ~3.2.0 + version: 3.2.0 + karma-coverage: + specifier: ~2.2.0 + version: 2.2.1 + karma-jasmine: + specifier: ~5.1.0 + version: 5.1.0(karma@6.4.4) + karma-jasmine-html-reporter: + specifier: ^2.0.0 + version: 2.1.0(jasmine-core@4.6.1)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4) + rfc4648: + specifier: ^1.5.0 + version: 1.5.4 + typescript: + specifier: ^5.7.3 + version: 5.7.3 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.5': + resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.0': + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.26.5': + resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.26.5': + resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.26.0': + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.26.5': + resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.26.5': + resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.26.5': + resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@evilmartians/lefthook@1.10.7': + resolution: {integrity: sha512-QQLKnBaaOFjl/sdu018AhqCoFI0bQSHRdC/kpCU4sANASwai/QBUzpzorR+xsi0++14/q7Nax04N60YF08fX4w==} + os: [darwin, linux, win32] + hasBin: true + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@module-federation/error-codes@0.8.4': + resolution: {integrity: sha512-55LYmrDdKb4jt+qr8qE8U3al62ZANp3FhfVaNPOaAmdTh0jHdD8M3yf5HKFlr5xVkVO4eV/F/J2NCfpbh+pEXQ==} + + '@module-federation/runtime-tools@0.8.4': + resolution: {integrity: sha512-fjVOsItJ1u5YY6E9FnS56UDwZgqEQUrWFnouRiPtK123LUuqUI9FH4redZoKWlE1PB0ir1Z3tnqy8eFYzPO38Q==} + + '@module-federation/runtime@0.8.4': + resolution: {integrity: sha512-yZeZ7z2Rx4gv/0E97oLTF3V6N25vglmwXGgoeju/W2YjsFvWzVtCDI7zRRb0mJhU6+jmSM8jP1DeQGbea/AiZQ==} + + '@module-federation/sdk@0.8.4': + resolution: {integrity: sha512-waABomIjg/5m1rPDBWYG4KUhS5r7OUUY7S+avpaVIY/tkPWB3ibRDKy2dNLLAMaLKq0u+B1qIdEp4NIWkqhqpg==} + + '@module-federation/webpack-bundler-runtime@0.8.4': + resolution: {integrity: sha512-HggROJhvHPUX7uqBD/XlajGygMNM1DG0+4OAkk8MBQe4a18QzrRNzZt6XQbRTSG4OaEoyRWhQHvYD3Yps405tQ==} + + '@ngify/core@2.0.4': + resolution: {integrity: sha512-MyZ6TrD4NEEEpy5yBoAtCyAXySpLvi6kBrvJk1vl9s4dkU5H+/bKBeAy5gPvdbLQ6c6NHD3Y4cnnMBbhdtVOnA==} + + '@ngify/http@2.0.4': + resolution: {integrity: sha512-3w3mMadsrkO0/xgLC5+79qqOJcRc+XvcBoOMZZBPFdiWg7wHWHULv48KHFVau+GbzvnXEza46W/xYyOMyR0F8g==} + peerDependencies: + rxjs: ^7.0.0 + + '@rsbuild/core@1.2.0-beta.0': + resolution: {integrity: sha512-gS1vTTbnYYjIpraEjRPhFMf1iH2rFrnV3cc3H6ZgAFQsksZzgYJyZBvAuigKPUW4SIl1mgnmMGWhLwZHgyZzOg==} + engines: {node: '>=16.7.0'} + hasBin: true + + '@rslib/core@0.3.1': + resolution: {integrity: sha512-CWGCoEEUC2/29MKcdtW6/QbjGR+7tEHZc9/w7pGG8fZAKtPAZx/lYNi/N63e/Q/MVWeU4GYRpG89EROFRantPQ==} + engines: {node: '>=16.0.0'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7 + typescript: ^5 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + typescript: + optional: true + + '@rspack/binding-darwin-arm64@1.2.0-beta.0': + resolution: {integrity: sha512-g8NgY4OIjZf5LabAKOHNr2rs/WzVaxXpOSVsdHztQL6ETdeEpIPUul4p/5zivFNcrvJxVVeHzKJHyB5lqxDcTA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@1.2.0-beta.0': + resolution: {integrity: sha512-+BH/1UpG96exJc6KhDOiSHAb05bUwxbYCd37HAJwcLxQgB7IEmPtBYvV5CtHysteM5NBtbNeeAcyXK+dIYvUew==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@1.2.0-beta.0': + resolution: {integrity: sha512-LdIBNy5WAXJ1J9nB3bEyvqz7mJrMN/7cCtPHMmFBR1aTFbh1NAjYZl24fc+f59aSV5jAU9wfTrOtqtSwnXg4tQ==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-arm64-musl@1.2.0-beta.0': + resolution: {integrity: sha512-4tRi87UyEWV25X6Ul68kJJ/de/cwmASmrIUrCYmdWEdtWMN46UOz0OvxCYvcHTf0DCP8M1CZf0cSiRuG/hsxGA==} + cpu: [arm64] + os: [linux] + + '@rspack/binding-linux-x64-gnu@1.2.0-beta.0': + resolution: {integrity: sha512-rWWrPwUH3V4yG46acZDIlqr7H/yCxbu+WdPhdIRBvgT7bisQkZa2HYx6MXmUXxx94U2iFy5bh+un0ho5FZOeCg==} + cpu: [x64] + os: [linux] + + '@rspack/binding-linux-x64-musl@1.2.0-beta.0': + resolution: {integrity: sha512-9pgL17Bk8aSrTBx6VfQbb313RInDjlY9DfgV5ybbSsWaFs/oAs4oPy+kepWWixfb9Y2q/74bSBPrBNTBYQpknw==} + cpu: [x64] + os: [linux] + + '@rspack/binding-win32-arm64-msvc@1.2.0-beta.0': + resolution: {integrity: sha512-JQ06Q3uTclIk4AvKUqx0Royx2PqVcUuumEUFVWERbd01fntkQqI3RFrPazBYAIvk5JmXk40+CKA1CsFef4RKOA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@1.2.0-beta.0': + resolution: {integrity: sha512-rNz/sXjXLAqCZkDuTumqm9Aa47Hiu45+vkJ0XvbirJ0A+dzuyGjdtlinwLyZtCY+dVAlS+AcX5znJLlpTSnjjA==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@1.2.0-beta.0': + resolution: {integrity: sha512-LKFcgaeEo7G6YLE5aKIbeWzXUpVZc02u0q4as0TjTTRBHd8r21GeaGJVh1Xm9YBkHpIX8Ho1DMftYVd+F6gHzw==} + cpu: [x64] + os: [win32] + + '@rspack/binding@1.2.0-beta.0': + resolution: {integrity: sha512-ZUBWVKCVC3uunZhjH7FAVLP83r/6QvPmYViA6n0JF3ycBmcJLkHJb26v42j6d5EfYfTtNvfRUlzHckIkFDQeDQ==} + + '@rspack/core@1.2.0-beta.0': + resolution: {integrity: sha512-0o0EYNeCwbRrh1l+P6HEKGS3Y+SVE/+J6SqDGGBsOixt/YzFeYNeaePWUnFfQ8a27jp9s//Ix6iuxMYGjWmitA==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@swc/helpers': '>=0.5.1' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@rspack/lite-tapable@1.0.1': + resolution: {integrity: sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w==} + engines: {node: '>=16.0.0'} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@types/cookie@0.4.1': + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + + '@types/cors@2.8.17': + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + + '@types/jasmine@4.6.4': + resolution: {integrity: sha512-qCw5sVW+ylTnrEhe5kfX4l6MgU9REXIVDa/lWEcvTOUmd+LqDYwyjovDq+Zk9blElaEHOj1URDQ/djEBVRf+pw==} + + '@types/node@22.10.7': + resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.1: + resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==} + engines: {node: '>= 0.4'} + + call-bound@1.0.3: + resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001692: + resolution: {integrity: sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copyfiles@2.4.1: + resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} + hasBin: true + + core-js@3.39.0: + resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + coveralls@3.1.1: + resolution: {integrity: sha512-+dxnG2NHncSD1NrqbSM3dn/lE57O6Qf/koe9+I7c+wzkqRmEvcp0kgJdxKInzYzkICKkFMZsX3Vct3++tsF9ww==} + engines: {node: '>=6'} + hasBin: true + + custom-event@1.0.1: + resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + date-format@4.0.14: + resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} + engines: {node: '>=4.0'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + di@0.0.1: + resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==} + + dom-serialize@2.2.1: + resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.83: + resolution: {integrity: sha512-LcUDPqSt+V0QmI47XLzZrz5OqILSMGsPFkDYus22rIbgorSvBYEFqq854ltTmUdHkY92FSdAAvsh4jWEULMdfQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.2: + resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} + engines: {node: '>=10.2.0'} + + ent@2.2.2: + resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fdir@6.4.2: + resolution: {integrity: sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.2.7: + resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + + har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + injection-js@https://codeload.github.com/mgechev/injection-js/tar.gz/81a10e0: + resolution: {tarball: https://codeload.github.com/mgechev/injection-js/tar.gz/81a10e0} + version: 2.4.0 + engines: {node: '>=8.5'} + + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-rslog@0.0.6: + resolution: {integrity: sha512-HM0q6XqQ93psDlqvuViNs/Ea3hAyGDkIdVAHlrEocjjAwGrs1fZ+EdQjS9eUPacnYB7Y8SoDdSY3H8p3ce205A==} + engines: {node: '>=14.17.6'} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jasmine-core@4.6.1: + resolution: {integrity: sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==} + + jasmine-spec-reporter@7.0.0: + resolution: {integrity: sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + + karma-chrome-launcher@3.2.0: + resolution: {integrity: sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==} + + karma-coverage@2.2.1: + resolution: {integrity: sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==} + engines: {node: '>=10.0.0'} + + karma-jasmine-html-reporter@2.1.0: + resolution: {integrity: sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==} + peerDependencies: + jasmine-core: ^4.0.0 || ^5.0.0 + karma: ^6.0.0 + karma-jasmine: ^5.0.0 + + karma-jasmine@5.1.0: + resolution: {integrity: sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==} + engines: {node: '>=12'} + peerDependencies: + karma: ^6.0.0 + + karma@6.4.4: + resolution: {integrity: sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==} + engines: {node: '>= 10'} + hasBin: true + + lcov-parse@1.0.0: + resolution: {integrity: sha512-aprLII/vPzuQvYZnDRU78Fns9I2Ag3gi4Ipga/hxnVMCZC8DnR2nI7XBqrPoywGfxqIx/DgarGvDJZAD3YBTgQ==} + hasBin: true + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-driver@1.2.7: + resolution: {integrity: sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==} + engines: {node: '>=0.8.6'} + + log4js@6.9.1: + resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} + engines: {node: '>=8.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + noms@0.0.0: + resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.3: + resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qjobs@1.2.0: + resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} + engines: {node: '>=0.9'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.5.3: + resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + readable-stream@1.0.34: + resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + + request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rsbuild-plugin-dts@0.3.1: + resolution: {integrity: sha512-oeD8ztSn0LBSNhUbIkYIxJKRMjd1Td2Dfhc1RefP0eouxkpnbQ+Dln3V5AVUGfDGK+BlubqEAF1gk3xAzLWA9w==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@microsoft/api-extractor': ^7 + '@rsbuild/core': 1.x + typescript: ^5 + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + typescript: + optional: true + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + streamroller@3.1.5: + resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} + engines: {node: '>=8.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + tinyglobby@0.2.10: + resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} + engines: {node: '>=12.0.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + ua-parser-js@0.7.40: + resolution: {integrity: sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==} + hasBin: true + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + + update-browserslist-db@1.1.2: + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + void-elements@2.0.1: + resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} + engines: {node: '>=0.10.0'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.5': {} + + '@babel/core@7.26.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/helper-compilation-targets': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helpers': 7.26.0 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.26.5': + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.26.5': + dependencies: + '@babel/compat-data': 7.26.5 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.26.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.26.0': + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + + '@babel/parser@7.26.5': + dependencies: + '@babel/types': 7.26.5 + + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + + '@babel/traverse@7.26.5': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.26.5': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@colors/colors@1.5.0': {} + + '@evilmartians/lefthook@1.10.7': {} + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@module-federation/error-codes@0.8.4': {} + + '@module-federation/runtime-tools@0.8.4': + dependencies: + '@module-federation/runtime': 0.8.4 + '@module-federation/webpack-bundler-runtime': 0.8.4 + + '@module-federation/runtime@0.8.4': + dependencies: + '@module-federation/error-codes': 0.8.4 + '@module-federation/sdk': 0.8.4 + + '@module-federation/sdk@0.8.4': + dependencies: + isomorphic-rslog: 0.0.6 + + '@module-federation/webpack-bundler-runtime@0.8.4': + dependencies: + '@module-federation/runtime': 0.8.4 + '@module-federation/sdk': 0.8.4 + + '@ngify/core@2.0.4': + dependencies: + tslib: 2.8.1 + + '@ngify/http@2.0.4(rxjs@7.8.1)': + dependencies: + '@ngify/core': 2.0.4 + rxjs: 7.8.1 + tslib: 2.8.1 + + '@rsbuild/core@1.2.0-beta.0': + dependencies: + '@rspack/core': 1.2.0-beta.0(@swc/helpers@0.5.15) + '@rspack/lite-tapable': 1.0.1 + '@swc/helpers': 0.5.15 + core-js: 3.39.0 + + '@rslib/core@0.3.1(typescript@5.7.3)': + dependencies: + '@rsbuild/core': 1.2.0-beta.0 + rsbuild-plugin-dts: 0.3.1(@rsbuild/core@1.2.0-beta.0)(typescript@5.7.3) + tinyglobby: 0.2.10 + optionalDependencies: + typescript: 5.7.3 + + '@rspack/binding-darwin-arm64@1.2.0-beta.0': + optional: true + + '@rspack/binding-darwin-x64@1.2.0-beta.0': + optional: true + + '@rspack/binding-linux-arm64-gnu@1.2.0-beta.0': + optional: true + + '@rspack/binding-linux-arm64-musl@1.2.0-beta.0': + optional: true + + '@rspack/binding-linux-x64-gnu@1.2.0-beta.0': + optional: true + + '@rspack/binding-linux-x64-musl@1.2.0-beta.0': + optional: true + + '@rspack/binding-win32-arm64-msvc@1.2.0-beta.0': + optional: true + + '@rspack/binding-win32-ia32-msvc@1.2.0-beta.0': + optional: true + + '@rspack/binding-win32-x64-msvc@1.2.0-beta.0': + optional: true + + '@rspack/binding@1.2.0-beta.0': + optionalDependencies: + '@rspack/binding-darwin-arm64': 1.2.0-beta.0 + '@rspack/binding-darwin-x64': 1.2.0-beta.0 + '@rspack/binding-linux-arm64-gnu': 1.2.0-beta.0 + '@rspack/binding-linux-arm64-musl': 1.2.0-beta.0 + '@rspack/binding-linux-x64-gnu': 1.2.0-beta.0 + '@rspack/binding-linux-x64-musl': 1.2.0-beta.0 + '@rspack/binding-win32-arm64-msvc': 1.2.0-beta.0 + '@rspack/binding-win32-ia32-msvc': 1.2.0-beta.0 + '@rspack/binding-win32-x64-msvc': 1.2.0-beta.0 + + '@rspack/core@1.2.0-beta.0(@swc/helpers@0.5.15)': + dependencies: + '@module-federation/runtime-tools': 0.8.4 + '@rspack/binding': 1.2.0-beta.0 + '@rspack/lite-tapable': 1.0.1 + caniuse-lite: 1.0.30001692 + optionalDependencies: + '@swc/helpers': 0.5.15 + + '@rspack/lite-tapable@1.0.1': {} + + '@socket.io/component-emitter@3.1.2': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@types/cookie@0.4.1': {} + + '@types/cors@2.8.17': + dependencies: + '@types/node': 22.10.7 + + '@types/jasmine@4.6.4': {} + + '@types/node@22.10.7': + dependencies: + undici-types: 6.20.0 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + asynckit@0.4.0: {} + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + balanced-match@1.0.2: {} + + base64id@2.0.0: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + binary-extensions@2.3.0: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001692 + electron-to-chromium: 1.5.83 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.1: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.3: + dependencies: + call-bind-apply-helpers: 1.0.1 + get-intrinsic: 1.2.7 + + caniuse-lite@1.0.30001692: {} + + caseless@0.12.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colors@1.4.0: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie@0.7.2: {} + + copyfiles@2.4.1: + dependencies: + glob: 7.2.3 + minimatch: 3.1.2 + mkdirp: 1.0.4 + noms: 0.0.0 + through2: 2.0.5 + untildify: 4.0.0 + yargs: 16.2.0 + + core-js@3.39.0: {} + + core-util-is@1.0.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + coveralls@3.1.1: + dependencies: + js-yaml: 3.14.1 + lcov-parse: 1.0.0 + log-driver: 1.2.7 + minimist: 1.2.8 + request: 2.88.2 + + custom-event@1.0.1: {} + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + date-format@4.0.14: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + di@0.0.1: {} + + dom-serialize@2.2.1: + dependencies: + custom-event: 1.0.1 + ent: 2.2.2 + extend: 3.0.2 + void-elements: 2.0.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.83: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + engine.io-parser@5.2.3: {} + + engine.io@6.6.2: + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 22.10.7 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + ent@2.2.2: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + punycode: 1.4.1 + safe-regex-test: 1.1.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + esprima@4.0.1: {} + + eventemitter3@4.0.7: {} + + extend@3.0.2: {} + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fdir@6.4.2(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + flatted@3.3.2: {} + + follow-redirects@1.15.9: {} + + forever-agent@0.6.1: {} + + form-data@2.3.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.2.7: + dependencies: + call-bind-apply-helpers: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@11.12.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + har-schema@2.0.0: {} + + har-validator@5.1.5: + dependencies: + ajv: 6.12.6 + har-schema: 2.0.0 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-escaper@2.0.2: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-signature@1.2.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + injection-js@https://codeload.github.com/mgechev/injection-js/tar.gz/81a10e0: + dependencies: + tslib: 2.8.1 + + interpret@1.4.0: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.3 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-typedarray@1.0.0: {} + + isarray@0.0.1: {} + + isarray@1.0.0: {} + + isbinaryfile@4.0.10: {} + + isexe@2.0.0: {} + + isomorphic-rslog@0.0.6: {} + + isstream@0.1.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.26.0 + '@babel/parser': 7.26.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jasmine-core@4.6.1: {} + + jasmine-spec-reporter@7.0.0: + dependencies: + colors: 1.4.0 + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsbn@0.1.1: {} + + jsesc@3.1.0: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stringify-safe@5.0.1: {} + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsprim@1.4.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + karma-chrome-launcher@3.2.0: + dependencies: + which: 1.3.1 + + karma-coverage@2.2.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 5.2.1 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + karma-jasmine-html-reporter@2.1.0(jasmine-core@4.6.1)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4): + dependencies: + jasmine-core: 4.6.1 + karma: 6.4.4 + karma-jasmine: 5.1.0(karma@6.4.4) + + karma-jasmine@5.1.0(karma@6.4.4): + dependencies: + jasmine-core: 4.6.1 + karma: 6.4.4 + + karma@6.4.4: + dependencies: + '@colors/colors': 1.5.0 + body-parser: 1.20.3 + braces: 3.0.3 + chokidar: 3.6.0 + connect: 3.7.0 + di: 0.0.1 + dom-serialize: 2.2.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + http-proxy: 1.18.1 + isbinaryfile: 4.0.10 + lodash: 4.17.21 + log4js: 6.9.1 + mime: 2.6.0 + minimatch: 3.1.2 + mkdirp: 0.5.6 + qjobs: 1.2.0 + range-parser: 1.2.1 + rimraf: 3.0.2 + socket.io: 4.8.1 + source-map: 0.6.1 + tmp: 0.2.3 + ua-parser-js: 0.7.40 + yargs: 16.2.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + lcov-parse@1.0.0: {} + + lodash@4.17.21: {} + + log-driver@1.2.7: {} + + log4js@6.9.1: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + flatted: 3.3.2 + rfdc: 1.4.1 + streamroller: 3.1.5 + transitivePeerDependencies: + - supports-color + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + node-releases@2.0.19: {} + + noms@0.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 1.0.34 + + normalize-path@3.0.0: {} + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.3: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: {} + + path-parse@1.0.7: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + process-nextick-args@2.0.1: {} + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@1.4.1: {} + + punycode@2.3.1: {} + + qjobs@1.2.0: {} + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + qs@6.5.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@1.0.34: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + rechoir@0.6.2: + dependencies: + resolve: 1.22.10 + + request@2.88.2: + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.3 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + + require-directory@2.1.1: {} + + requires-port@1.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rfc4648@1.5.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rsbuild-plugin-dts@0.3.1(@rsbuild/core@1.2.0-beta.0)(typescript@5.7.3): + dependencies: + '@rsbuild/core': 1.2.0-beta.0 + magic-string: 0.30.17 + picocolors: 1.1.1 + tinyglobby: 0.2.10 + optionalDependencies: + typescript: 5.7.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + setprototypeof@1.2.0: {} + + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.3 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.3 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.3 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.3 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.2 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + streamroller@3.1.5: + dependencies: + date-format: 4.0.14 + debug: 4.4.0 + fs-extra: 8.1.0 + transitivePeerDependencies: + - supports-color + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@0.10.31: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + tinyglobby@0.2.10: + dependencies: + fdir: 6.4.2(picomatch@4.0.2) + picomatch: 4.0.2 + + tmp@0.2.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@2.5.0: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@0.14.5: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.7.3: {} + + ua-parser-js@0.7.40: {} + + undici-types@6.20.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + untildify@4.0.0: {} + + update-browserslist-db@1.1.2(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@3.4.0: {} + + vary@1.1.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + void-elements@2.0.1: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.17.1: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 diff --git a/rslib.config.ts b/rslib.config.ts new file mode 100644 index 0000000..21b55eb --- /dev/null +++ b/rslib.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + source: { + tsconfigPath: './tsconfig.lib.json' + }, + lib: [ + { + format: 'esm', + syntax: 'es2021', + dts: true, + }, + { + format: 'cjs', + syntax: 'es2021', + }, + ], + output: { + target: 'web', + }, +}); diff --git a/src/api/data.service.spec.ts b/src/api/data.service.spec.ts new file mode 100644 index 0000000..ba33128 --- /dev/null +++ b/src/api/data.service.spec.ts @@ -0,0 +1,192 @@ +import { + HttpHeaders, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { DataService } from './data.service'; +import { HttpBaseService } from './http-base.service'; + +describe('Data Service', () => { + let dataService: DataService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + DataService, + HttpBaseService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + }); + + beforeEach(() => { + dataService = TestBed.inject(DataService); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should create', () => { + expect(dataService).toBeTruthy(); + }); + + describe('get', () => { + it('get call sets the accept header', waitForAsync(() => { + const url = 'testurl'; + + dataService + .get(url, { configId: 'configId1' }) + .subscribe((data: unknown) => { + expect(data).toBe('bodyData'); + }); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('get call with token the accept header and the token', waitForAsync(() => { + const url = 'testurl'; + const token = 'token'; + + dataService + .get(url, { configId: 'configId1' }, token) + .subscribe((data: unknown) => { + expect(data).toBe('bodyData'); + }); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + expect(req.request.headers.get('Authorization')).toBe('Bearer ' + token); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('call without ngsw-bypass param by default', waitForAsync(() => { + const url = 'testurl'; + + dataService + .get(url, { configId: 'configId1' }) + .subscribe((data: unknown) => { + expect(data).toBe('bodyData'); + }); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + expect(req.request.params.get('ngsw-bypass')).toBeNull(); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('call with ngsw-bypass param', waitForAsync(() => { + const url = 'testurl'; + + dataService + .get(url, { configId: 'configId1', ngswBypass: true }) + .subscribe((data: unknown) => { + expect(data).toBe('bodyData'); + }); + const req = httpMock.expectOne(url + '?ngsw-bypass='); + + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + expect(req.request.params.get('ngsw-bypass')).toBe(''); + + req.flush('bodyData'); + + httpMock.verify(); + })); + }); + + describe('post', () => { + it('call sets the accept header when no other params given', waitForAsync(() => { + const url = 'testurl'; + + dataService + .post(url, { some: 'thing' }, { configId: 'configId1' }) + .subscribe(); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('call sets custom headers ONLY (No ACCEPT header) when custom headers are given', waitForAsync(() => { + const url = 'testurl'; + let headers = new HttpHeaders(); + + headers = headers.set('X-MyHeader', 'Genesis'); + + dataService + .post(url, { some: 'thing' }, { configId: 'configId1' }, headers) + .subscribe(); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get('X-MyHeader')).toEqual('Genesis'); + expect(req.request.headers.get('X-MyHeader')).not.toEqual('Genesis333'); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('call without ngsw-bypass param by default', waitForAsync(() => { + const url = 'testurl'; + + dataService + .post(url, { some: 'thing' }, { configId: 'configId1' }) + .subscribe(); + const req = httpMock.expectOne(url); + + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + expect(req.request.params.get('ngsw-bypass')).toBeNull(); + + req.flush('bodyData'); + + httpMock.verify(); + })); + + it('call with ngsw-bypass param', waitForAsync(() => { + const url = 'testurl'; + + dataService + .post( + url, + { some: 'thing' }, + { configId: 'configId1', ngswBypass: true } + ) + .subscribe(); + const req = httpMock.expectOne(url + '?ngsw-bypass='); + + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get('Accept')).toBe('application/json'); + expect(req.request.params.get('ngsw-bypass')).toBe(''); + + req.flush('bodyData'); + + httpMock.verify(); + })); + }); +}); diff --git a/src/api/data.service.ts b/src/api/data.service.ts new file mode 100644 index 0000000..2a034b6 --- /dev/null +++ b/src/api/data.service.ts @@ -0,0 +1,65 @@ +import { HttpHeaders, HttpParams } from '@ngify/http'; +import { Injectable, inject } from 'injection-js'; +import { Observable } from 'rxjs'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { HttpBaseService } from './http-base.service'; + +const NGSW_CUSTOM_PARAM = 'ngsw-bypass'; + +@Injectable() +export class DataService { + private readonly httpClient = inject(HttpBaseService); + + get( + url: string, + config: OpenIdConfiguration, + token?: string + ): Observable { + const headers = this.prepareHeaders(token); + const params = this.prepareParams(config); + + return this.httpClient.get(url, { + headers, + params, + }); + } + + post( + url: string | null, + body: unknown, + config: OpenIdConfiguration, + headersParams?: HttpHeaders + ): Observable { + const headers = headersParams || this.prepareHeaders(); + const params = this.prepareParams(config); + + return this.httpClient.post(url ?? '', body, { headers, params }); + } + + private prepareHeaders(token?: string): HttpHeaders { + let headers = new HttpHeaders(); + + headers = headers.set('Accept', 'application/json'); + + if (!!token) { + headers = headers.set( + 'Authorization', + 'Bearer ' + decodeURIComponent(token) + ); + } + + return headers; + } + + private prepareParams(config: OpenIdConfiguration): HttpParams { + let params = new HttpParams(); + + const { ngswBypass } = config; + + if (ngswBypass) { + params = params.set(NGSW_CUSTOM_PARAM, ''); + } + + return params; + } +} diff --git a/src/api/http-base.service.ts b/src/api/http-base.service.ts new file mode 100644 index 0000000..9673397 --- /dev/null +++ b/src/api/http-base.service.ts @@ -0,0 +1,22 @@ +import { HttpClient } from '@ngify/http'; +import { Injectable, inject } from 'injection-js'; +import { Observable } from 'rxjs'; + +@Injectable() +export class HttpBaseService { + constructor() {} + + private readonly http = inject(HttpClient); + + get(url: string, params?: { [key: string]: unknown }): Observable { + return this.http.get(url, params); + } + + post( + url: string, + body: unknown, + params?: { [key: string]: unknown } + ): Observable { + return this.http.post(url, body, params); + } +} diff --git a/src/auth-config.spec.ts b/src/auth-config.spec.ts new file mode 100644 index 0000000..c1e706d --- /dev/null +++ b/src/auth-config.spec.ts @@ -0,0 +1,17 @@ +import { PassedInitialConfig, createStaticLoader } from './auth-config'; + +describe('AuthConfig', () => { + describe('createStaticLoader', () => { + it('should throw an error if no config is provided', () => { + // Arrange + const passedConfig = {} as PassedInitialConfig; + + // Act + + // Assert + expect(() => createStaticLoader(passedConfig)).toThrowError( + 'No config provided!' + ); + }); + }); +}); diff --git a/src/auth-config.ts b/src/auth-config.ts new file mode 100644 index 0000000..9c785ec --- /dev/null +++ b/src/auth-config.ts @@ -0,0 +1,25 @@ +import { InjectionToken, Provider } from 'injection-js'; +import { + StsConfigLoader, + StsConfigStaticLoader, +} from './config/loader/config-loader'; +import { OpenIdConfiguration } from './config/openid-configuration'; + +export interface PassedInitialConfig { + config?: OpenIdConfiguration | OpenIdConfiguration[]; + loader?: Provider; +} + +export function createStaticLoader( + passedConfig: PassedInitialConfig +): StsConfigLoader { + if (!passedConfig?.config) { + throw new Error('No config provided!'); + } + + return new StsConfigStaticLoader(passedConfig.config); +} + +export const PASSED_CONFIG = new InjectionToken( + 'PASSED_CONFIG' +); diff --git a/src/auth-options.ts b/src/auth-options.ts new file mode 100644 index 0000000..436929b --- /dev/null +++ b/src/auth-options.ts @@ -0,0 +1,12 @@ +export interface AuthOptions { + customParams?: { [key: string]: string | number | boolean }; + urlHandler?(url: string): void; + /** overrides redirectUrl from configuration */ + redirectUrl?: string; +} + +export interface LogoutAuthOptions { + customParams?: { [key: string]: string | number | boolean }; + urlHandler?(url: string): void; + logoffMethod?: 'GET' | 'POST'; +} diff --git a/src/auth-state/auth-result.ts b/src/auth-state/auth-result.ts new file mode 100644 index 0000000..0d15085 --- /dev/null +++ b/src/auth-state/auth-result.ts @@ -0,0 +1,9 @@ +export interface AuthenticatedResult { + isAuthenticated: boolean; + allConfigsAuthenticated: ConfigAuthenticatedResult[]; +} + +export interface ConfigAuthenticatedResult { + configId: string; + isAuthenticated: boolean; +} diff --git a/src/auth-state/auth-state.service.spec.ts b/src/auth-state/auth-state.service.spec.ts new file mode 100644 index 0000000..7f1f239 --- /dev/null +++ b/src/auth-state/auth-state.service.spec.ts @@ -0,0 +1,638 @@ +import { TestBed } from '@angular/core/testing'; +import { Observable } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { PlatformProvider } from '../utils/platform-provider/platform.provider'; +import { TokenValidationService } from '../validation/token-validation.service'; +import { ValidationResult } from '../validation/validation-result'; +import { AuthStateService } from './auth-state.service'; + +describe('Auth State Service', () => { + let authStateService: AuthStateService; + let storagePersistenceService: StoragePersistenceService; + let eventsService: PublicEventsService; + let tokenValidationService: TokenValidationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AuthStateService, + PublicEventsService, + mockProvider(LoggerService), + mockProvider(TokenValidationService), + mockProvider(PlatformProvider), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + authStateService = TestBed.inject(AuthStateService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + eventsService = TestBed.inject(PublicEventsService); + tokenValidationService = TestBed.inject(TokenValidationService); + }); + + it('should create', () => { + expect(authStateService).toBeTruthy(); + }); + + it('public authorize$ is observable$', () => { + expect(authStateService.authenticated$).toEqual(jasmine.any(Observable)); + }); + + describe('setAuthorizedAndFireEvent', () => { + it('throws correct event with single config', () => { + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setAuthenticatedAndFireEvent([ + { configId: 'configId1' }, + ]); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: true, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: true }, + ], + }); + }); + + it('throws correct event with multiple configs', () => { + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setAuthenticatedAndFireEvent([ + { configId: 'configId1' }, + { configId: 'configId2' }, + ]); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: false }, + { configId: 'configId2', isAuthenticated: false }, + ], + }); + }); + + it('throws correct event with multiple configs, one is authenticated', () => { + const allConfigs = [{ configId: 'configId1' }, { configId: 'configId2' }]; + + spyOn(storagePersistenceService, 'getAccessToken') + .withArgs(allConfigs[0]) + .and.returnValue('someAccessToken') + .withArgs(allConfigs[1]) + .and.returnValue(''); + + spyOn(storagePersistenceService, 'getIdToken') + .withArgs(allConfigs[0]) + .and.returnValue('someIdToken') + .withArgs(allConfigs[1]) + .and.returnValue(''); + + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setAuthenticatedAndFireEvent(allConfigs); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: true }, + { configId: 'configId2', isAuthenticated: false }, + ], + }); + }); + }); + + describe('setUnauthorizedAndFireEvent', () => { + it('persist AuthState In Storage', () => { + const spy = spyOn(storagePersistenceService, 'resetAuthStateInStorage'); + + authStateService.setUnauthenticatedAndFireEvent( + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + expect(spy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + + it('throws correct event with single config', () => { + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setUnauthenticatedAndFireEvent( + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: false }, + ], + }); + }); + + it('throws correct event with multiple configs', () => { + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setUnauthenticatedAndFireEvent( + { configId: 'configId1' }, + [{ configId: 'configId1' }, { configId: 'configId2' }] + ); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: false }, + { configId: 'configId2', isAuthenticated: false }, + ], + }); + }); + + it('throws correct event with multiple configs, one is authenticated', () => { + spyOn(storagePersistenceService, 'getAccessToken') + .withArgs({ configId: 'configId1' }) + .and.returnValue('someAccessToken') + .withArgs({ configId: 'configId2' }) + .and.returnValue(''); + + spyOn(storagePersistenceService, 'getIdToken') + .withArgs({ configId: 'configId1' }) + .and.returnValue('someIdToken') + .withArgs({ configId: 'configId2' }) + .and.returnValue(''); + + const spy = spyOn( + (authStateService as any).authenticatedInternal$, + 'next' + ); + + authStateService.setUnauthenticatedAndFireEvent( + { configId: 'configId1' }, + [{ configId: 'configId1' }, { configId: 'configId2' }] + ); + + expect(spy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: 'configId1', isAuthenticated: true }, + { configId: 'configId2', isAuthenticated: false }, + ], + }); + }); + }); + + describe('updateAndPublishAuthState', () => { + it('calls eventsService', () => { + spyOn(eventsService, 'fireEvent'); + + authStateService.updateAndPublishAuthState({ + isAuthenticated: false, + isRenewProcess: false, + validationResult: {} as ValidationResult, + }); + + expect(eventsService.fireEvent).toHaveBeenCalledOnceWith( + EventTypes.NewAuthenticationResult, + jasmine.any(Object) + ); + }); + }); + + describe('setAuthorizationData', () => { + it('stores accessToken', () => { + const spy = spyOn(storagePersistenceService, 'write'); + const authResult = { + id_token: 'idtoken', + access_token: 'accesstoken', + expires_in: 330, + token_type: 'Bearer', + refresh_token: '9UuSQKx_UaGJSEvfHW2NK6FxAPSVvK-oVyeOb1Sstz0', + scope: 'openid profile email taler_api offline_access', + state: '7bad349c97cd7391abb6dfc41ec8c8e8ee8yeprJL', + session_state: + 'gjNckdb8h4HS5us_3oz68oqsAhvNMOMpgsJNqrhy7kM.rBe66j0WPYpSx_c4vLM-5w', + }; + + authStateService.setAuthorizationData( + 'accesstoken', + authResult, + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.calls.allArgs()).toEqual([ + ['authzData', 'accesstoken', { configId: 'configId1' }], + [ + 'access_token_expires_at', + jasmine.any(Number), + { configId: 'configId1' }, + ], + ]); + }); + + it('does not crash and store accessToken when authResult is null', () => { + const spy = spyOn(storagePersistenceService, 'write'); + const authResult = null; + + authStateService.setAuthorizationData( + 'accesstoken', + authResult, + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('calls setAuthenticatedAndFireEvent() method', () => { + const spy = spyOn(authStateService, 'setAuthenticatedAndFireEvent'); + const authResult = { + id_token: 'idtoken', + access_token: 'accesstoken', + expires_in: 330, + token_type: 'Bearer', + refresh_token: '9UuSQKx_UaGJSEvfHW2NK6FxAPSVvK-oVyeOb1Sstz0', + scope: 'openid profile email taler_api offline_access', + state: '7bad349c97cd7391abb6dfc41ec8c8e8ee8yeprJL', + session_state: + 'gjNckdb8h4HS5us_3oz68oqsAhvNMOMpgsJNqrhy7kM.rBe66j0WPYpSx_c4vLM-5w', + }; + + authStateService.setAuthorizationData( + 'not used', + authResult, + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('getAccessToken', () => { + it('isAuthorized is false returns null', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + const result = authStateService.getAccessToken({ configId: 'configId1' }); + + expect(result).toBe(''); + }); + + it('returns false if storagePersistenceService returns something falsy but authorized', () => { + spyOn(authStateService, 'isAuthenticated').and.returnValue(true); + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + const result = authStateService.getAccessToken({ configId: 'configId1' }); + + expect(result).toBe(''); + }); + + it('isAuthorized is true returns decodeURIComponent(token)', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + const result = authStateService.getAccessToken({ configId: 'configId1' }); + + expect(result).toBe(decodeURIComponent('HenloLegger')); + }); + }); + + describe('getAuthenticationResult', () => { + it('isAuthorized is false returns null', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + + spyOn(storagePersistenceService, 'getAuthenticationResult') + .withArgs({ configId: 'configId1' }) + .and.returnValue({}); + + const result = authStateService.getAuthenticationResult({ + configId: 'configId1', + }); + + expect(result).toBe(null); + }); + + it('returns false if storagePersistenceService returns something falsy but authorized', () => { + spyOn(authStateService, 'isAuthenticated').and.returnValue(true); + spyOn(storagePersistenceService, 'getAuthenticationResult') + .withArgs({ configId: 'configId1' }) + .and.returnValue({}); + + const result = authStateService.getAuthenticationResult({ + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('isAuthorized is true returns object', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + spyOn(storagePersistenceService, 'getAuthenticationResult') + .withArgs({ configId: 'configId1' }) + .and.returnValue({ scope: 'HenloFuriend' }); + + const result = authStateService.getAuthenticationResult({ + configId: 'configId1', + }); + + expect(result?.scope).toBe('HenloFuriend'); + }); + }); + + describe('getIdToken', () => { + it('isAuthorized is false returns null', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + const result = authStateService.getIdToken({ configId: 'configId1' }); + + expect(result).toBe(''); + }); + + it('isAuthorized is true returns decodeURIComponent(token)', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + const result = authStateService.getIdToken({ configId: 'configId1' }); + + expect(result).toBe(decodeURIComponent('HenloFuriend')); + }); + }); + + describe('getRefreshToken', () => { + it('isAuthorized is false returns null', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + const result = authStateService.getRefreshToken({ + configId: 'configId1', + }); + + expect(result).toBe(''); + }); + + it('isAuthorized is true returns decodeURIComponent(token)', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + 'HenloRefreshLegger' + ); + const result = authStateService.getRefreshToken({ + configId: 'configId1', + }); + + expect(result).toBe(decodeURIComponent('HenloRefreshLegger')); + }); + }); + + describe('areAuthStorageTokensValid', () => { + it('isAuthorized is false returns false', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue(''); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + const result = authStateService.areAuthStorageTokensValid({ + configId: 'configId1', + }); + + expect(result).toBeFalse(); + }); + + it('isAuthorized is true and id_token is expired returns true', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + + spyOn( + authStateService as any, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(true); + spyOn( + authStateService as any, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(false); + const result = authStateService.areAuthStorageTokensValid({ + configId: 'configId1', + }); + + expect(result).toBeFalse(); + }); + + it('isAuthorized is true and access_token is expired returns true', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + + spyOn( + authStateService as any, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(false); + spyOn( + authStateService as any, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(true); + const result = authStateService.areAuthStorageTokensValid({ + configId: 'configId1', + }); + + expect(result).toBeFalse(); + }); + + it('isAuthorized is true and id_token is not expired returns true', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + + spyOn( + authStateService as any, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(false); + spyOn( + authStateService as any, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(false); + const result = authStateService.areAuthStorageTokensValid({ + configId: 'configId1', + }); + + expect(result).toBeTrue(); + }); + + it('authState is AuthorizedState.Authorized and id_token is not expired fires event', () => { + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'HenloLegger' + ); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'HenloFuriend' + ); + + spyOn( + authStateService as any, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(false); + spyOn( + authStateService as any, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(false); + const result = authStateService.areAuthStorageTokensValid({ + configId: 'configId1', + }); + + expect(result).toBeTrue(); + }); + }); + + describe('hasIdTokenExpiredAndRenewCheckIsEnabled', () => { + it('tokenValidationService gets called with id token if id_token is set', () => { + const config = { + configId: 'configId1', + renewTimeBeforeTokenExpiresInSeconds: 30, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn(storagePersistenceService, 'getIdToken') + .withArgs(config) + .and.returnValue('idToken'); + const spy = spyOn( + tokenValidationService, + 'hasIdTokenExpired' + ).and.callFake((_a, _b) => true); + + authStateService.hasIdTokenExpiredAndRenewCheckIsEnabled(config); + + expect(spy).toHaveBeenCalledOnceWith('idToken', config, 30); + }); + + it('fires event if idToken is expired', () => { + spyOn(tokenValidationService, 'hasIdTokenExpired').and.callFake( + (_a, _b) => true + ); + + const spy = spyOn(eventsService, 'fireEvent'); + const config = { + configId: 'configId1', + renewTimeBeforeTokenExpiresInSeconds: 30, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authnResult', config) + .and.returnValue('idToken'); + + const result = + authStateService.hasIdTokenExpiredAndRenewCheckIsEnabled(config); + + expect(result).toBe(true); + expect(spy).toHaveBeenCalledOnceWith(EventTypes.IdTokenExpired, true); + }); + + it('does NOT fire event if idToken is NOT expired', () => { + spyOn(tokenValidationService, 'hasIdTokenExpired').and.callFake( + (_a, _b) => false + ); + + const spy = spyOn(eventsService, 'fireEvent'); + const config = { + configId: 'configId1', + renewTimeBeforeTokenExpiresInSeconds: 30, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authnResult', config) + .and.returnValue('idToken'); + const result = + authStateService.hasIdTokenExpiredAndRenewCheckIsEnabled(config); + + expect(result).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('hasAccessTokenExpiredIfExpiryExists', () => { + it('negates the result of internal call of `validateAccessTokenNotExpired`', () => { + const validateAccessTokenNotExpiredResult = true; + const expectedResult = !validateAccessTokenNotExpiredResult; + const date = new Date(new Date().toUTCString()); + const config = { + configId: 'configId1', + renewTimeBeforeTokenExpiresInSeconds: 5, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('access_token_expires_at', config) + .and.returnValue(date); + const spy = spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(validateAccessTokenNotExpiredResult); + const result = + authStateService.hasAccessTokenExpiredIfExpiryExists(config); + + expect(spy).toHaveBeenCalledOnceWith(date, config, 5); + expect(result).toEqual(expectedResult); + }); + + it('throws event when token is expired', () => { + const validateAccessTokenNotExpiredResult = false; + const expectedResult = !validateAccessTokenNotExpiredResult; + // spyOn(configurationProvider, 'getOpenIDConfiguration').and.returnValue({ renewTimeBeforeTokenExpiresInSeconds: 5 }); + const date = new Date(new Date().toUTCString()); + const config = { + configId: 'configId1', + renewTimeBeforeTokenExpiresInSeconds: 5, + }; + + spyOn(eventsService, 'fireEvent'); + + spyOn(storagePersistenceService, 'read') + .withArgs('access_token_expires_at', config) + .and.returnValue(date); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(validateAccessTokenNotExpiredResult); + authStateService.hasAccessTokenExpiredIfExpiryExists(config); + expect(eventsService.fireEvent).toHaveBeenCalledOnceWith( + EventTypes.TokenExpired, + expectedResult + ); + }); + }); +}); diff --git a/src/auth-state/auth-state.service.ts b/src/auth-state/auth-state.service.ts new file mode 100644 index 0000000..7c51a94 --- /dev/null +++ b/src/auth-state/auth-state.service.ts @@ -0,0 +1,330 @@ +import { Injectable, inject } from 'injection-js'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { AuthResult } from '../flows/callback-context'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { TokenValidationService } from '../validation/token-validation.service'; +import { AuthenticatedResult } from './auth-result'; +import { AuthStateResult } from './auth-state'; + +const DEFAULT_AUTHRESULT = { + isAuthenticated: false, + allConfigsAuthenticated: [], +}; + +@Injectable() +export class AuthStateService { + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly loggerService = inject(LoggerService); + + private readonly publicEventsService = inject(PublicEventsService); + + private readonly tokenValidationService = inject(TokenValidationService); + + private readonly authenticatedInternal$ = + new BehaviorSubject(DEFAULT_AUTHRESULT); + + get authenticated$(): Observable { + return this.authenticatedInternal$ + .asObservable() + .pipe(distinctUntilChanged()); + } + + setAuthenticatedAndFireEvent(allConfigs: OpenIdConfiguration[]): void { + const result = this.composeAuthenticatedResult(allConfigs); + + this.authenticatedInternal$.next(result); + } + + setUnauthenticatedAndFireEvent( + currentConfig: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + this.storagePersistenceService.resetAuthStateInStorage(currentConfig); + + const result = this.composeUnAuthenticatedResult(allConfigs); + + this.authenticatedInternal$.next(result); + } + + updateAndPublishAuthState(authenticationResult: AuthStateResult): void { + this.publicEventsService.fireEvent( + EventTypes.NewAuthenticationResult, + authenticationResult + ); + } + + setAuthorizationData( + accessToken: string, + authResult: AuthResult | null, + currentConfig: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + this.loggerService.logDebug( + currentConfig, + `storing the accessToken '${accessToken}'` + ); + + this.storagePersistenceService.write( + 'authzData', + accessToken, + currentConfig + ); + this.persistAccessTokenExpirationTime(authResult, currentConfig); + this.setAuthenticatedAndFireEvent(allConfigs); + } + + getAccessToken(configuration: OpenIdConfiguration | null): string { + if (!configuration) { + return ''; + } + + if (!this.isAuthenticated(configuration)) { + return ''; + } + + const token = this.storagePersistenceService.getAccessToken(configuration); + + return this.decodeURIComponentSafely(token); + } + + getIdToken(configuration: OpenIdConfiguration | null): string { + if (!configuration) { + return ''; + } + + if (!this.isAuthenticated(configuration)) { + return ''; + } + + const token = this.storagePersistenceService.getIdToken(configuration); + + return this.decodeURIComponentSafely(token); + } + + getRefreshToken(configuration: OpenIdConfiguration | null): string { + if (!configuration) { + return ''; + } + + if (!this.isAuthenticated(configuration)) { + return ''; + } + + const token = this.storagePersistenceService.getRefreshToken(configuration); + + return this.decodeURIComponentSafely(token); + } + + getAuthenticationResult( + configuration: OpenIdConfiguration | null + ): AuthResult | null { + if (!configuration) { + return null; + } + + if (!this.isAuthenticated(configuration)) { + return null; + } + + return this.storagePersistenceService.getAuthenticationResult( + configuration + ); + } + + areAuthStorageTokensValid( + configuration: OpenIdConfiguration | null + ): boolean { + if (!configuration) { + return false; + } + + if (!this.isAuthenticated(configuration)) { + return false; + } + + if (this.hasIdTokenExpiredAndRenewCheckIsEnabled(configuration)) { + this.loggerService.logDebug( + configuration, + 'persisted idToken is expired' + ); + + return false; + } + + if (this.hasAccessTokenExpiredIfExpiryExists(configuration)) { + this.loggerService.logDebug( + configuration, + 'persisted accessToken is expired' + ); + + return false; + } + + this.loggerService.logDebug( + configuration, + 'persisted idToken and accessToken are valid' + ); + + return true; + } + + hasIdTokenExpiredAndRenewCheckIsEnabled( + configuration: OpenIdConfiguration + ): boolean { + const { + renewTimeBeforeTokenExpiresInSeconds, + triggerRefreshWhenIdTokenExpired, + disableIdTokenValidation, + } = configuration; + + if (!triggerRefreshWhenIdTokenExpired || disableIdTokenValidation) { + return false; + } + const tokenToCheck = + this.storagePersistenceService.getIdToken(configuration); + + const idTokenExpired = this.tokenValidationService.hasIdTokenExpired( + tokenToCheck, + configuration, + renewTimeBeforeTokenExpiresInSeconds + ); + + if (idTokenExpired) { + this.publicEventsService.fireEvent( + EventTypes.IdTokenExpired, + idTokenExpired + ); + } + + return idTokenExpired; + } + + hasAccessTokenExpiredIfExpiryExists( + configuration: OpenIdConfiguration + ): boolean { + const { renewTimeBeforeTokenExpiresInSeconds } = configuration; + const accessTokenExpiresIn = this.storagePersistenceService.read( + 'access_token_expires_at', + configuration + ); + const accessTokenHasNotExpired = + this.tokenValidationService.validateAccessTokenNotExpired( + accessTokenExpiresIn, + configuration, + renewTimeBeforeTokenExpiresInSeconds + ); + + const hasExpired = !accessTokenHasNotExpired; + + if (hasExpired) { + this.publicEventsService.fireEvent( + EventTypes.TokenExpired, + hasExpired + ); + } + + return hasExpired; + } + + isAuthenticated(configuration: OpenIdConfiguration | null): boolean { + if (!configuration) { + throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + + return false; + } + + const hasAccessToken = + !!this.storagePersistenceService.getAccessToken(configuration); + const hasIdToken = + !!this.storagePersistenceService.getIdToken(configuration); + + return hasAccessToken && hasIdToken; + } + + private decodeURIComponentSafely(token: string): string { + if (token) { + return decodeURIComponent(token); + } else { + return ''; + } + } + + private persistAccessTokenExpirationTime( + authResult: AuthResult | null, + configuration: OpenIdConfiguration + ): void { + if (authResult?.expires_in) { + const accessTokenExpiryTime = + new Date(new Date().toUTCString()).valueOf() + + authResult.expires_in * 1000; + + this.storagePersistenceService.write( + 'access_token_expires_at', + accessTokenExpiryTime, + configuration + ); + } + } + + private composeAuthenticatedResult( + allConfigs: OpenIdConfiguration[] + ): AuthenticatedResult { + if (allConfigs.length === 1) { + const { configId } = allConfigs[0]; + + return { + isAuthenticated: true, + allConfigsAuthenticated: [ + { configId: configId ?? '', isAuthenticated: true }, + ], + }; + } + + return this.checkAllConfigsIfTheyAreAuthenticated(allConfigs); + } + + private composeUnAuthenticatedResult( + allConfigs: OpenIdConfiguration[] + ): AuthenticatedResult { + if (allConfigs.length === 1) { + const { configId } = allConfigs[0]; + + return { + isAuthenticated: false, + allConfigsAuthenticated: [ + { configId: configId ?? '', isAuthenticated: false }, + ], + }; + } + + return this.checkAllConfigsIfTheyAreAuthenticated(allConfigs); + } + + private checkAllConfigsIfTheyAreAuthenticated( + allConfigs: OpenIdConfiguration[] + ): AuthenticatedResult { + const allConfigsAuthenticated = allConfigs.map((config) => ({ + configId: config.configId ?? '', + isAuthenticated: this.isAuthenticated(config), + })); + + const isAuthenticated = allConfigsAuthenticated.every( + (x) => !!x.isAuthenticated + ); + + return { allConfigsAuthenticated, isAuthenticated }; + } +} diff --git a/src/auth-state/auth-state.ts b/src/auth-state/auth-state.ts new file mode 100644 index 0000000..a8e2191 --- /dev/null +++ b/src/auth-state/auth-state.ts @@ -0,0 +1,7 @@ +import { ValidationResult } from '../validation/validation-result'; + +export interface AuthStateResult { + isAuthenticated: boolean; + validationResult: ValidationResult; + isRenewProcess: boolean; +} diff --git a/src/auth-state/check-auth.service.spec.ts b/src/auth-state/check-auth.service.spec.ts new file mode 100644 index 0000000..d7f9534 --- /dev/null +++ b/src/auth-state/check-auth.service.spec.ts @@ -0,0 +1,907 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { mockAbstractProvider, mockProvider } from '../../test/auto-mock'; +import { AutoLoginService } from '../auto-login/auto-login.service'; +import { CallbackService } from '../callback/callback.service'; +import { PeriodicallyTokenCheckService } from '../callback/periodically-token-check.service'; +import { RefreshSessionService } from '../callback/refresh-session.service'; +import { + StsConfigLoader, + StsConfigStaticLoader, +} from '../config/loader/config-loader'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { CheckSessionService } from '../iframe/check-session.service'; +import { SilentRenewService } from '../iframe/silent-renew.service'; +import { LoggerService } from '../logging/logger.service'; +import { LoginResponse } from '../login/login-response'; +import { PopUpService } from '../login/popup/popup.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { CurrentUrlService } from '../utils/url/current-url.service'; +import { AuthStateService } from './auth-state.service'; +import { CheckAuthService } from './check-auth.service'; + +describe('CheckAuthService', () => { + let checkAuthService: CheckAuthService; + let authStateService: AuthStateService; + let userService: UserService; + let checkSessionService: CheckSessionService; + let callBackService: CallbackService; + let silentRenewService: SilentRenewService; + let periodicallyTokenCheckService: PeriodicallyTokenCheckService; + let refreshSessionService: RefreshSessionService; + let popUpService: PopUpService; + let autoLoginService: AutoLoginService; + let storagePersistenceService: StoragePersistenceService; + let currentUrlService: CurrentUrlService; + let publicEventsService: PublicEventsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [ + mockProvider(CheckSessionService), + mockProvider(SilentRenewService), + mockProvider(UserService), + mockProvider(LoggerService), + mockProvider(AuthStateService), + mockProvider(CallbackService), + mockProvider(RefreshSessionService), + mockProvider(PeriodicallyTokenCheckService), + mockProvider(PopUpService), + mockProvider(CurrentUrlService), + mockProvider(PublicEventsService), + mockAbstractProvider(StsConfigLoader, StsConfigStaticLoader), + AutoLoginService, + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + checkAuthService = TestBed.inject(CheckAuthService); + refreshSessionService = TestBed.inject(RefreshSessionService); + userService = TestBed.inject(UserService); + authStateService = TestBed.inject(AuthStateService); + checkSessionService = TestBed.inject(CheckSessionService); + callBackService = TestBed.inject(CallbackService); + silentRenewService = TestBed.inject(SilentRenewService); + periodicallyTokenCheckService = TestBed.inject( + PeriodicallyTokenCheckService + ); + popUpService = TestBed.inject(PopUpService); + autoLoginService = TestBed.inject(AutoLoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + currentUrlService = TestBed.inject(CurrentUrlService); + publicEventsService = TestBed.inject(PublicEventsService); + }); + + afterEach(() => { + storagePersistenceService.clear({} as OpenIdConfiguration); + }); + + it('should create', () => { + expect(checkAuthService).toBeTruthy(); + }); + + describe('checkAuth', () => { + it('uses config with matching state when url has state param and config with state param is stored', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + 'the-state-param' + ); + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(storagePersistenceService, 'read') + .withArgs('authStateControl', allConfigs[0]) + .and.returnValue('the-state-param'); + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + allConfigs[0], + allConfigs, + undefined + ); + }); + })); + + it('throws error when url has state param and stored config with matching state param is not found', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + 'the-state-param' + ); + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(storagePersistenceService, 'read') + .withArgs('authStateControl', allConfigs[0]) + .and.returnValue('not-matching-state-param'); + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + expect(spy).not.toHaveBeenCalled(); + }, + }); + })); + + it('uses first/default config when no param is passed', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + null + ); + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + { configId: 'configId1', authority: 'some-authority' }, + allConfigs, + undefined + ); + }); + })); + + it('returns null and sendMessageToMainWindow if currently in a popup', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(popUpService as any, 'canAccessSessionStorage').and.returnValue( + true + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOnProperty(popUpService as any, 'windowInternal').and.returnValue({ + opener: {} as Window, + }); + spyOn(storagePersistenceService, 'read').and.returnValue(null); + + spyOn(popUpService, 'isCurrentlyInPopup').and.returnValue(true); + const popupSpy = spyOn(popUpService, 'sendMessageToMainWindow'); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId: '', + }); + expect(popupSpy).toHaveBeenCalled(); + }); + })); + + it('returns isAuthenticated: false with error message in case handleCallbackAndFireEvents throws an error', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(true); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + const spy = spyOn( + callBackService, + 'handleCallbackAndFireEvents' + ).and.returnValue(throwError(() => new Error('ERROR'))); + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: 'ERROR', + configId: 'configId1', + idToken: '', + userData: null, + accessToken: '', + }); + expect(spy).toHaveBeenCalled(); + }); + })); + + it('calls callbackService.handlePossibleStsCallback with current url when callback is true', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(true); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'getAccessToken').and.returnValue('at'); + spyOn(authStateService, 'getIdToken').and.returnValue('idt'); + + const spy = spyOn( + callBackService, + 'handleCallbackAndFireEvents' + ).and.returnValue(of({} as CallbackContext)); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: true, + userData: undefined, + accessToken: 'at', + configId: 'configId1', + idToken: 'idt', + }); + expect(spy).toHaveBeenCalled(); + }); + })); + + it('does NOT call handleCallbackAndFireEvents with current url when callback is false', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + const spy = spyOn( + callBackService, + 'handleCallbackAndFireEvents' + ).and.returnValue(of({} as CallbackContext)); + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'getAccessToken').and.returnValue('at'); + spyOn(authStateService, 'getIdToken').and.returnValue('idt'); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: true, + userData: undefined, + accessToken: 'at', + configId: 'configId1', + idToken: 'idt', + }); + expect(spy).not.toHaveBeenCalled(); + }); + })); + + it('does fire the auth and user data events when it is not a callback from the security token service and is authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(userService, 'getUserDataFromStore').and.returnValue({ + some: 'user-data', + }); + spyOn(authStateService, 'getAccessToken').and.returnValue('at'); + spyOn(authStateService, 'getIdToken').and.returnValue('idt'); + + const setAuthorizedAndFireEventSpy = spyOn( + authStateService, + 'setAuthenticatedAndFireEvent' + ); + const userServiceSpy = spyOn(userService, 'publishUserDataIfExists'); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: true, + userData: { + some: 'user-data', + }, + accessToken: 'at', + configId: 'configId1', + idToken: 'idt', + }); + expect(setAuthorizedAndFireEventSpy).toHaveBeenCalled(); + expect(userServiceSpy).toHaveBeenCalled(); + }); + })); + + it('does NOT fire the auth and user data events when it is not a callback from the security token service and is NOT authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOn(authStateService, 'getAccessToken').and.returnValue('at'); + spyOn(authStateService, 'getIdToken').and.returnValue('it'); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + + const setAuthorizedAndFireEventSpy = spyOn( + authStateService, + 'setAuthenticatedAndFireEvent' + ); + const userServiceSpy = spyOn(userService, 'publishUserDataIfExists'); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + userData: undefined, + accessToken: 'at', + configId: 'configId1', + idToken: 'it', + }); + expect(setAuthorizedAndFireEventSpy).not.toHaveBeenCalled(); + expect(userServiceSpy).not.toHaveBeenCalled(); + }); + })); + + it('if authenticated return true', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'getAccessToken').and.returnValue('at'); + spyOn(authStateService, 'getIdToken').and.returnValue('idt'); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + checkAuthService + .checkAuth(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: true, + userData: undefined, + accessToken: 'at', + configId: 'configId1', + idToken: 'idt', + }); + }); + })); + + it('if authenticated set auth and fires event ', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + const spy = spyOn(authStateService, 'setAuthenticatedAndFireEvent'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('if authenticated publishUserdataIfExists', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + const spy = spyOn(userService, 'publishUserDataIfExists'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('if authenticated callbackService startTokenValidationPeriodically', waitForAsync(() => { + const config = { + authority: 'authority', + tokenRefreshInSeconds: 7, + }; + const allConfigs = [config]; + + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + const spy = spyOn( + periodicallyTokenCheckService, + 'startTokenValidationPeriodically' + ); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('if isCheckSessionConfigured call checkSessionService.start()', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(checkSessionService, 'isCheckSessionConfigured').and.returnValue( + true + ); + const spy = spyOn(checkSessionService, 'start'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('if isSilentRenewConfigured call getOrCreateIframe()', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(silentRenewService, 'isSilentRenewConfigured').and.returnValue( + true + ); + const spy = spyOn(silentRenewService, 'getOrCreateIframe'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('calls checkSavedRedirectRouteAndNavigate if authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const spy = spyOn(autoLoginService, 'checkSavedRedirectRouteAndNavigate'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledOnceWith(allConfigs[0]); + }); + })); + + it('does not call checkSavedRedirectRouteAndNavigate if not authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const spy = spyOn(autoLoginService, 'checkSavedRedirectRouteAndNavigate'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalledTimes(0); + }); + })); + + it('fires CheckingAuth-Event on start and finished event on end', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + + const fireEventSpy = spyOn(publicEventsService, 'fireEvent'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(fireEventSpy.calls.allArgs()).toEqual([ + [EventTypes.CheckingAuth], + [EventTypes.CheckingAuthFinished], + ]); + }); + })); + + it('fires CheckingAuth-Event on start and CheckingAuthFinishedWithError event on end if exception occurs', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + const fireEventSpy = spyOn(publicEventsService, 'fireEvent'); + + spyOn(callBackService, 'isCallback').and.returnValue(true); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + throwError(() => new Error('ERROR')) + ); + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(fireEventSpy.calls.allArgs()).toEqual([ + [EventTypes.CheckingAuth], + [EventTypes.CheckingAuthFinishedWithError, 'ERROR'], + ]); + }); + })); + + it('fires CheckingAuth-Event on start and finished event on end if not authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(currentUrlService, 'getCurrentUrl').and.returnValue( + 'http://localhost:4200' + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + + const fireEventSpy = spyOn(publicEventsService, 'fireEvent'); + + checkAuthService.checkAuth(allConfigs[0], allConfigs).subscribe(() => { + expect(fireEventSpy.calls.allArgs()).toEqual([ + [EventTypes.CheckingAuth], + [EventTypes.CheckingAuthFinished], + ]); + }); + })); + }); + + describe('checkAuthIncludingServer', () => { + it('if isSilentRenewConfigured call getOrCreateIframe()', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + + spyOn(silentRenewService, 'isSilentRenewConfigured').and.returnValue( + true + ); + const spy = spyOn(silentRenewService, 'getOrCreateIframe'); + + checkAuthService + .checkAuthIncludingServer(allConfigs[0], allConfigs) + .subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('does forceRefreshSession get called and is NOT authenticated', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + of({ + idToken: 'idToken', + accessToken: 'access_token', + isAuthenticated: false, + userData: null, + configId: 'configId1', + }) + ); + + checkAuthService + .checkAuthIncludingServer(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toBeTruthy(); + }); + })); + + it('should start check session and validation after forceRefreshSession has been called and is authenticated after forcing with silentrenew', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(checkSessionService, 'isCheckSessionConfigured').and.returnValue( + true + ); + spyOn(silentRenewService, 'isSilentRenewConfigured').and.returnValue( + true + ); + + const checkSessionServiceStartSpy = spyOn(checkSessionService, 'start'); + const periodicallyTokenCheckServiceSpy = spyOn( + periodicallyTokenCheckService, + 'startTokenValidationPeriodically' + ); + const getOrCreateIframeSpy = spyOn( + silentRenewService, + 'getOrCreateIframe' + ); + + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + of({ + idToken: 'idToken', + accessToken: 'access_token', + isAuthenticated: true, + userData: null, + configId: 'configId1', + }) + ); + + checkAuthService + .checkAuthIncludingServer(allConfigs[0], allConfigs) + .subscribe(() => { + expect(checkSessionServiceStartSpy).toHaveBeenCalledOnceWith( + allConfigs[0] + ); + expect(periodicallyTokenCheckServiceSpy).toHaveBeenCalledTimes(1); + expect(getOrCreateIframeSpy).toHaveBeenCalledOnceWith(allConfigs[0]); + }); + })); + + it('should start check session and validation after forceRefreshSession has been called and is authenticated after forcing without silentrenew', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority' }, + ]; + + spyOn(callBackService, 'isCallback').and.returnValue(false); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOn(callBackService, 'handleCallbackAndFireEvents').and.returnValue( + of({} as CallbackContext) + ); + spyOn(checkSessionService, 'isCheckSessionConfigured').and.returnValue( + true + ); + spyOn(silentRenewService, 'isSilentRenewConfigured').and.returnValue( + false + ); + + const checkSessionServiceStartSpy = spyOn(checkSessionService, 'start'); + const periodicallyTokenCheckServiceSpy = spyOn( + periodicallyTokenCheckService, + 'startTokenValidationPeriodically' + ); + const getOrCreateIframeSpy = spyOn( + silentRenewService, + 'getOrCreateIframe' + ); + + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + of({ + idToken: 'idToken', + accessToken: 'access_token', + isAuthenticated: true, + userData: null, + configId: 'configId1', + }) + ); + + checkAuthService + .checkAuthIncludingServer(allConfigs[0], allConfigs) + .subscribe(() => { + expect(checkSessionServiceStartSpy).toHaveBeenCalledOnceWith( + allConfigs[0] + ); + expect(periodicallyTokenCheckServiceSpy).toHaveBeenCalledTimes(1); + expect(getOrCreateIframeSpy).not.toHaveBeenCalled(); + }); + })); + }); + + describe('checkAuthMultiple', () => { + it('uses config with matching state when url has state param and config with state param is stored', waitForAsync(() => { + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority1' }, + { configId: 'configId2', authority: 'some-authority2' }, + ]; + + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + 'the-state-param' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authStateControl', allConfigs[0]) + .and.returnValue('the-state-param'); + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuthMultiple(allConfigs).subscribe((result) => { + expect(Array.isArray(result)).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.calls.argsFor(0)).toEqual([ + allConfigs[0], + allConfigs, + undefined, + ]); + expect(spy.calls.argsFor(1)).toEqual([ + allConfigs[1], + allConfigs, + undefined, + ]); + }); + })); + + it('uses config from passed configId if configId was passed and returns all results', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + null + ); + + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority1' }, + { configId: 'configId2', authority: 'some-authority2' }, + ]; + + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuthMultiple(allConfigs).subscribe((result) => { + expect(Array.isArray(result)).toBe(true); + expect(spy.calls.allArgs()).toEqual([ + [ + { configId: 'configId1', authority: 'some-authority1' }, + allConfigs, + undefined, + ], + [ + { configId: 'configId2', authority: 'some-authority2' }, + allConfigs, + undefined, + ], + ]); + }); + })); + + it('runs through all configs if no parameter is passed and has no state in url', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + null + ); + + const allConfigs = [ + { configId: 'configId1', authority: 'some-authority1' }, + { configId: 'configId2', authority: 'some-authority2' }, + ]; + + const spy = spyOn( + checkAuthService as any, + 'checkAuthWithConfig' + ).and.callThrough(); + + checkAuthService.checkAuthMultiple(allConfigs).subscribe((result) => { + expect(Array.isArray(result)).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.calls.argsFor(0)).toEqual([ + { configId: 'configId1', authority: 'some-authority1' }, + allConfigs, + undefined, + ]); + expect(spy.calls.argsFor(1)).toEqual([ + { configId: 'configId2', authority: 'some-authority2' }, + allConfigs, + undefined, + ]); + }); + })); + + it('throws error if url has state param but no config could be found', waitForAsync(() => { + spyOn(currentUrlService, 'getStateParamFromCurrentUrl').and.returnValue( + 'the-state-param' + ); + + const allConfigs: OpenIdConfiguration[] = []; + + checkAuthService.checkAuthMultiple(allConfigs).subscribe({ + error: (error) => { + expect(error.message).toEqual( + 'could not find matching config for state the-state-param' + ); + }, + }); + })); + }); +}); diff --git a/src/auth-state/check-auth.service.ts b/src/auth-state/check-auth.service.ts new file mode 100644 index 0000000..eaddadc --- /dev/null +++ b/src/auth-state/check-auth.service.ts @@ -0,0 +1,362 @@ +import { inject, Injectable } from 'injection-js'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { AutoLoginService } from '../auto-login/auto-login.service'; +import { CallbackService } from '../callback/callback.service'; +import { PeriodicallyTokenCheckService } from '../callback/periodically-token-check.service'; +import { RefreshSessionService } from '../callback/refresh-session.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CheckSessionService } from '../iframe/check-session.service'; +import { SilentRenewService } from '../iframe/silent-renew.service'; +import { LoggerService } from '../logging/logger.service'; +import { LoginResponse } from '../login/login-response'; +import { PopUpService } from '../login/popup/popup.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { CurrentUrlService } from '../utils/url/current-url.service'; +import { AuthStateService } from './auth-state.service'; + +@Injectable() +export class CheckAuthService { + private readonly checkSessionService = inject(CheckSessionService); + + private readonly currentUrlService = inject(CurrentUrlService); + + private readonly silentRenewService = inject(SilentRenewService); + + private readonly userService = inject(UserService); + + private readonly loggerService = inject(LoggerService); + + private readonly authStateService = inject(AuthStateService); + + private readonly callbackService = inject(CallbackService); + + private readonly refreshSessionService = inject(RefreshSessionService); + + private readonly periodicallyTokenCheckService = inject( + PeriodicallyTokenCheckService + ); + + private readonly popupService = inject(PopUpService); + + private readonly autoLoginService = inject(AutoLoginService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly publicEventsService = inject(PublicEventsService); + + private getConfig( + configuration: OpenIdConfiguration, + url: string | undefined + ): OpenIdConfiguration | null { + const stateParamFromUrl = + this.currentUrlService.getStateParamFromCurrentUrl(url); + + return Boolean(stateParamFromUrl) + ? this.getConfigurationWithUrlState([configuration], stateParamFromUrl) + : configuration; + } + + checkAuth( + configuration: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[], + url?: string + ): Observable { + if (!configuration) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + this.publicEventsService.fireEvent(EventTypes.CheckingAuth); + + const stateParamFromUrl = + this.currentUrlService.getStateParamFromCurrentUrl(url); + const config = this.getConfig(configuration, url); + + if (!config) { + return throwError( + () => + new Error( + `could not find matching config for state ${stateParamFromUrl}` + ) + ); + } + + return this.checkAuthWithConfig(configuration, allConfigs, url); + } + + checkAuthMultiple( + allConfigs: OpenIdConfiguration[], + url?: string + ): Observable { + const stateParamFromUrl = + this.currentUrlService.getStateParamFromCurrentUrl(url); + + if (stateParamFromUrl) { + const config = this.getConfigurationWithUrlState( + allConfigs, + stateParamFromUrl + ); + + if (!config) { + return throwError( + () => + new Error( + `could not find matching config for state ${stateParamFromUrl}` + ) + ); + } + + return this.composeMultipleLoginResults(allConfigs, config, url); + } + + const configs = allConfigs; + const allChecks$ = configs.map((x) => + this.checkAuthWithConfig(x, configs, url) + ); + + return forkJoin(allChecks$); + } + + checkAuthIncludingServer( + configuration: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[] + ): Observable { + if (!configuration) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + return this.checkAuthWithConfig(configuration, allConfigs).pipe( + switchMap((loginResponse) => { + const { isAuthenticated } = loginResponse; + + if (isAuthenticated) { + return of(loginResponse); + } + + return this.refreshSessionService + .forceRefreshSession(configuration, allConfigs) + .pipe( + tap((loginResponseAfterRefreshSession) => { + if (loginResponseAfterRefreshSession?.isAuthenticated) { + this.startCheckSessionAndValidation(configuration, allConfigs); + } + }) + ); + }) + ); + } + + private checkAuthWithConfig( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + url?: string + ): Observable { + if (!config) { + const errorMessage = + 'Please provide at least one configuration before setting up the module'; + + this.loggerService.logError(config, errorMessage); + + const result: LoginResponse = { + isAuthenticated: false, + errorMessage, + userData: null, + idToken: '', + accessToken: '', + configId: '', + }; + + return of(result); + } + + const currentUrl = url || this.currentUrlService.getCurrentUrl(); + + if (!currentUrl) { + const errorMessage = 'No URL found!'; + + this.loggerService.logError(config, errorMessage); + + const result: LoginResponse = { + isAuthenticated: false, + errorMessage, + userData: null, + idToken: '', + accessToken: '', + configId: '', + }; + + return of(result); + } + + const { configId, authority } = config; + + this.loggerService.logDebug( + config, + `Working with config '${configId}' using '${authority}'` + ); + + if (this.popupService.isCurrentlyInPopup(config)) { + this.popupService.sendMessageToMainWindow(currentUrl, config); + + const result: LoginResponse = { + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId: '', + }; + + return of(result); + } + + const isCallback = this.callbackService.isCallback(currentUrl, config); + + this.loggerService.logDebug( + config, + `currentUrl to check auth with: '${currentUrl}'` + ); + + const callback$ = isCallback + ? this.callbackService.handleCallbackAndFireEvents( + currentUrl, + config, + allConfigs + ) + : of({}); + + return callback$.pipe( + map(() => { + const isAuthenticated = + this.authStateService.areAuthStorageTokensValid(config); + + this.loggerService.logDebug( + config, + `checkAuth completed. Firing events now. isAuthenticated: ${isAuthenticated}` + ); + + if (isAuthenticated) { + this.startCheckSessionAndValidation(config, allConfigs); + + if (!isCallback) { + this.authStateService.setAuthenticatedAndFireEvent(allConfigs); + this.userService.publishUserDataIfExists(config, allConfigs); + } + } + this.publicEventsService.fireEvent(EventTypes.CheckingAuthFinished); + + const result: LoginResponse = { + isAuthenticated, + userData: this.userService.getUserDataFromStore(config), + accessToken: this.authStateService.getAccessToken(config), + idToken: this.authStateService.getIdToken(config), + configId, + }; + + return result; + }), + tap(({ isAuthenticated }) => { + if (isAuthenticated) { + this.autoLoginService.checkSavedRedirectRouteAndNavigate(config); + } + }), + catchError(({ message }) => { + this.loggerService.logError(config, message); + this.publicEventsService.fireEvent( + EventTypes.CheckingAuthFinishedWithError, + message + ); + + const result: LoginResponse = { + isAuthenticated: false, + errorMessage: message, + userData: null, + idToken: '', + accessToken: '', + configId, + }; + + return of(result); + }) + ); + } + + private startCheckSessionAndValidation( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + if (this.checkSessionService.isCheckSessionConfigured(config)) { + this.checkSessionService.start(config); + } + + this.periodicallyTokenCheckService.startTokenValidationPeriodically( + allConfigs, + config + ); + + if (this.silentRenewService.isSilentRenewConfigured(config)) { + this.silentRenewService.getOrCreateIframe(config); + } + } + + private getConfigurationWithUrlState( + configurations: OpenIdConfiguration[], + stateFromUrl: string | null + ): OpenIdConfiguration | null { + if (!stateFromUrl) { + return null; + } + + for (const config of configurations) { + const storedState = this.storagePersistenceService.read( + 'authStateControl', + config + ); + + if (storedState === stateFromUrl) { + return config; + } + } + + return null; + } + + private composeMultipleLoginResults( + configurations: OpenIdConfiguration[], + activeConfig: OpenIdConfiguration, + url?: string + ): Observable { + const allOtherConfigs = configurations.filter( + (x) => x.configId !== activeConfig.configId + ); + + const currentConfigResult = this.checkAuthWithConfig( + activeConfig, + configurations, + url + ); + + const allOtherConfigResults = allOtherConfigs.map((config) => { + const { redirectUrl } = config; + + return this.checkAuthWithConfig(config, configurations, redirectUrl); + }); + + return forkJoin([currentConfigResult, ...allOtherConfigResults]); + } +} diff --git a/src/auth.module.spec.ts b/src/auth.module.spec.ts new file mode 100644 index 0000000..8e06b77 --- /dev/null +++ b/src/auth.module.spec.ts @@ -0,0 +1,61 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../test/auto-mock'; +import { PASSED_CONFIG } from './auth-config'; +import { AuthModule } from './auth.module'; +import { ConfigurationService } from './config/config.service'; +import { + StsConfigHttpLoader, + StsConfigLoader, + StsConfigStaticLoader, +} from './config/loader/config-loader'; + +describe('AuthModule', () => { + describe('APP_CONFIG', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [AuthModule.forRoot({ config: { authority: 'something' } })], + providers: [mockProvider(ConfigurationService)], + }).compileComponents(); + })); + + it('should create', () => { + expect(AuthModule).toBeDefined(); + expect(AuthModule.forRoot({})).toBeDefined(); + }); + + it('should provide config', () => { + const config = TestBed.inject(PASSED_CONFIG); + + expect(config).toEqual({ config: { authority: 'something' } }); + }); + + it('should create StsConfigStaticLoader if config is passed', () => { + const configLoader = TestBed.inject(StsConfigLoader); + + expect(configLoader instanceof StsConfigStaticLoader).toBe(true); + }); + }); + + describe('StsConfigHttpLoader', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + AuthModule.forRoot({ + loader: { + provide: StsConfigLoader, + useFactory: () => new StsConfigHttpLoader(of({})), + }, + }), + ], + providers: [mockProvider(ConfigurationService)], + }).compileComponents(); + })); + + it('should create StsConfigStaticLoader if config is passed', () => { + const configLoader = TestBed.inject(StsConfigLoader); + + expect(configLoader instanceof StsConfigHttpLoader).toBe(true); + }); + }); +}); diff --git a/src/auth.module.ts b/src/auth.module.ts new file mode 100644 index 0000000..79c734c --- /dev/null +++ b/src/auth.module.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@ngify/http'; +import { ModuleWithProviders, NgModule } from 'injection-js'; +import { PassedInitialConfig } from './auth-config'; +import { _provideAuth } from './provide-auth'; + +@NgModule({ + declarations: [], + exports: [], + imports: [CommonModule], + providers: [provideHttpClient(withInterceptorsFromDi())], +}) +export class AuthModule { + static forRoot( + passedConfig: PassedInitialConfig + ): ModuleWithProviders { + return { + ngModule: AuthModule, + providers: [..._provideAuth(passedConfig)], + }; + } +} diff --git a/src/auto-login/auto-login-all-routes.guard.spec.ts b/src/auto-login/auto-login-all-routes.guard.spec.ts new file mode 100644 index 0000000..0bdb75d --- /dev/null +++ b/src/auto-login/auto-login-all-routes.guard.spec.ts @@ -0,0 +1,402 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Observable, of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CheckAuthService } from '../auth-state/check-auth.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoginResponse } from '../login/login-response'; +import { LoginService } from '../login/login.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { AutoLoginAllRoutesGuard } from './auto-login-all-routes.guard'; +import { AutoLoginService } from './auto-login.service'; + +describe(`AutoLoginAllRoutesGuard`, () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [ + AutoLoginService, + mockProvider(CheckAuthService), + mockProvider(LoginService), + mockProvider(StoragePersistenceService), + mockProvider(ConfigurationService), + ], + }); + }); + + describe('Class based', () => { + let guard: AutoLoginAllRoutesGuard; + let checkAuthService: CheckAuthService; + let loginService: LoginService; + let storagePersistenceService: StoragePersistenceService; + let configurationService: ConfigurationService; + let autoLoginService: AutoLoginService; + let router: Router; + + beforeEach(() => { + checkAuthService = TestBed.inject(CheckAuthService); + loginService = TestBed.inject(LoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + configurationService = TestBed.inject(ConfigurationService); + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of({ configId: 'configId1' }) + ); + + guard = TestBed.inject(AutoLoginAllRoutesGuard); + autoLoginService = TestBed.inject(AutoLoginService); + router = TestBed.inject(Router); + }); + + afterEach(() => { + storagePersistenceService.clear({}); + }); + + it('should create', () => { + expect(guard).toBeTruthy(); + }); + + describe('canActivate', () => { + it('should save current route and call `login` if not authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: false } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const canActivate$ = guard.canActivate( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivate$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canActivate$ = guard.canActivate( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivate$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ + configId: 'configId1', + }); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canActivate$ = guard.canActivate( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivate$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + + describe('canActivateChild', () => { + it('should save current route and call `login` if not authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: false } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canActivateChild$ = guard.canActivateChild( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivateChild$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canActivateChild$ = guard.canActivateChild( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivateChild$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ + configId: 'configId1', + }); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canActivateChild$ = guard.canActivateChild( + {} as ActivatedRouteSnapshot, + { + url: 'some-url1', + } as RouterStateSnapshot + ) as Observable; + + canActivateChild$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + + describe('canLoad', () => { + it('should save current route (empty) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: false } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canLoad$ = guard.canLoad(); + + canLoad$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should save current route (with router extractedUrl) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: false } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const _routerSpy = spyOn( + router, + 'getCurrentNavigation' + ).and.returnValue({ + extractedUrl: router.parseUrl( + 'some-url12/with/some-param?queryParam=true' + ), + extras: {}, + id: 1, + initialUrl: router.parseUrl(''), + previousNavigation: null, + trigger: 'imperative', + }); + const canLoad$ = guard.canLoad(); + + canLoad$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url12/with/some-param?queryParam=true' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canLoad$ = guard.canLoad() as Observable; + + canLoad$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ + configId: 'configId1', + }); + }); + })); + + it('should save current route (with router extractedUrl) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: false } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const _routerSpy = spyOn( + router, + 'getCurrentNavigation' + ).and.returnValue({ + extractedUrl: router.parseUrl( + 'some-url12/with/some-param?queryParam=true' + ), + extras: {}, + id: 1, + initialUrl: router.parseUrl(''), + previousNavigation: null, + trigger: 'imperative', + }); + const canLoad$ = guard.canLoad(); + + canLoad$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url12/with/some-param?queryParam=true' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ isAuthenticated: true } as LoginResponse) + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + const canLoad$ = guard.canLoad() as Observable; + + canLoad$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + }); +}); diff --git a/src/auto-login/auto-login-all-routes.guard.ts b/src/auto-login/auto-login-all-routes.guard.ts new file mode 100644 index 0000000..ef8d80b --- /dev/null +++ b/src/auto-login/auto-login-all-routes.guard.ts @@ -0,0 +1,101 @@ +import { inject, Injectable } from 'injection-js'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { CheckAuthService } from '../auth-state/check-auth.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoginService } from '../login/login.service'; +import { AutoLoginService } from './auto-login.service'; + +/** + * @deprecated Please do not use the `AutoLoginAllRoutesGuard` anymore as it is not recommended anymore, deprecated and will be removed in future versions of this library. More information [Why is AutoLoginAllRoutesGuard not recommended?](https://github.com/damienbod/angular-auth-oidc-client/issues/1549) + */ +@Injectable() +export class AutoLoginAllRoutesGuard { + private readonly autoLoginService = inject(AutoLoginService); + + private readonly checkAuthService = inject(CheckAuthService); + + private readonly loginService = inject(LoginService); + + private readonly configurationService = inject(ConfigurationService); + + private readonly router = inject(Router); + + canLoad(): Observable { + const url = + this.router + .getCurrentNavigation() + ?.extractedUrl.toString() + .substring(1) ?? ''; + + return checkAuth( + url, + this.configurationService, + this.checkAuthService, + this.autoLoginService, + this.loginService + ); + } + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return checkAuth( + state.url, + this.configurationService, + this.checkAuthService, + this.autoLoginService, + this.loginService + ); + } + + canActivateChild( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return checkAuth( + state.url, + this.configurationService, + this.checkAuthService, + this.autoLoginService, + this.loginService + ); + } +} + +function checkAuth( + url: string, + configurationService: ConfigurationService, + checkAuthService: CheckAuthService, + autoLoginService: AutoLoginService, + loginService: LoginService +): Observable { + return configurationService.getOpenIDConfiguration().pipe( + switchMap((config) => { + const allConfigs = configurationService.getAllConfigurations(); + + return checkAuthService.checkAuth(config, allConfigs).pipe( + take(1), + map(({ isAuthenticated }) => { + if (isAuthenticated) { + autoLoginService.checkSavedRedirectRouteAndNavigate(config); + } + + if (!isAuthenticated) { + autoLoginService.saveRedirectRoute(config, url); + loginService.login(config); + } + + return isAuthenticated; + }) + ); + }) + ); +} diff --git a/src/auto-login/auto-login-partial-routes.guard.spec.ts b/src/auto-login/auto-login-partial-routes.guard.spec.ts new file mode 100644 index 0000000..672e12b --- /dev/null +++ b/src/auto-login/auto-login-partial-routes.guard.spec.ts @@ -0,0 +1,556 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { CheckAuthService } from '../auth-state/check-auth.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoginService } from '../login/login.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { + AutoLoginPartialRoutesGuard, + autoLoginPartialRoutesGuard, + autoLoginPartialRoutesGuardWithConfig, +} from './auto-login-partial-routes.guard'; +import { AutoLoginService } from './auto-login.service'; + +describe(`AutoLoginPartialRoutesGuard`, () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [ + AutoLoginService, + mockProvider(AuthStateService), + mockProvider(LoginService), + mockProvider(StoragePersistenceService), + mockProvider(CheckAuthService), + mockProvider(ConfigurationService), + ], + }); + }); + + describe('Class based', () => { + let guard: AutoLoginPartialRoutesGuard; + let loginService: LoginService; + let authStateService: AuthStateService; + let storagePersistenceService: StoragePersistenceService; + let configurationService: ConfigurationService; + let autoLoginService: AutoLoginService; + let router: Router; + + beforeEach(() => { + authStateService = TestBed.inject(AuthStateService); + loginService = TestBed.inject(LoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + configurationService = TestBed.inject(ConfigurationService); + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of({ configId: 'configId1' }) + ); + + guard = TestBed.inject(AutoLoginPartialRoutesGuard); + autoLoginService = TestBed.inject(AutoLoginService); + router = TestBed.inject(Router); + }); + + afterEach(() => { + storagePersistenceService.clear({}); + }); + + it('should create', () => { + expect(guard).toBeTruthy(); + }); + + describe('canActivate', () => { + it('should save current route and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivate( + {} as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ + configId: 'configId1', + }); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).not.toHaveBeenCalled(); + }); + })); + + it('should save current route and call `login` if not authenticated already and add custom params', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivate( + { data: { custom: 'param' } } as unknown as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + { customParams: { custom: 'param' } } + ); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivate( + {} as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + + describe('canActivateChild', () => { + it('should save current route and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivateChild( + {} as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ + configId: 'configId1', + }); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).not.toHaveBeenCalled(); + }); + })); + + it('should save current route and call `login` if not authenticated already with custom params', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivateChild( + { data: { custom: 'param' } } as unknown as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url1' + ); + expect(loginSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + { customParams: { custom: 'param' } } + ); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard + .canActivateChild( + {} as ActivatedRouteSnapshot, + { url: 'some-url1' } as RouterStateSnapshot + ) + .subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + + describe('canLoad', () => { + it('should save current route (empty) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard.canLoad().subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should save current route (with router extractedUrl) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + spyOn(router, 'getCurrentNavigation').and.returnValue({ + extractedUrl: router.parseUrl( + 'some-url12/with/some-param?queryParam=true' + ), + extras: {}, + id: 1, + initialUrl: router.parseUrl(''), + previousNavigation: null, + trigger: 'imperative', + }); + + guard.canLoad().subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url12/with/some-param?queryParam=true' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + guard.canLoad().subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + }); + + describe('functional', () => { + describe('autoLoginPartialRoutesGuard', () => { + let loginService: LoginService; + let authStateService: AuthStateService; + let storagePersistenceService: StoragePersistenceService; + let configurationService: ConfigurationService; + let autoLoginService: AutoLoginService; + let router: Router; + + beforeEach(() => { + authStateService = TestBed.inject(AuthStateService); + loginService = TestBed.inject(LoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + configurationService = TestBed.inject(ConfigurationService); + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of({ configId: 'configId1' }) + ); + + autoLoginService = TestBed.inject(AutoLoginService); + router = TestBed.inject(Router); + }); + + afterEach(() => { + storagePersistenceService.clear({}); + }); + + it('should save current route (empty) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const guard$ = TestBed.runInInjectionContext( + autoLoginPartialRoutesGuard + ); + + guard$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should save current route (with router extractedUrl) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(router, 'getCurrentNavigation').and.returnValue({ + extractedUrl: router.parseUrl( + 'some-url12/with/some-param?queryParam=true' + ), + extras: {}, + id: 1, + initialUrl: router.parseUrl(''), + previousNavigation: null, + trigger: 'imperative', + }); + + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const guard$ = TestBed.runInInjectionContext( + autoLoginPartialRoutesGuard + ); + + guard$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'some-url12/with/some-param?queryParam=true' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should save current route and call `login` if not authenticated already and add custom params', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const guard$ = TestBed.runInInjectionContext(() => + autoLoginPartialRoutesGuard({ + data: { custom: 'param' }, + } as unknown as ActivatedRouteSnapshot) + ); + + guard$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '' + ); + expect(loginSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + { customParams: { custom: 'param' } } + ); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call `checkSavedRedirectRouteAndNavigate` if authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const guard$ = TestBed.runInInjectionContext( + autoLoginPartialRoutesGuard + ); + + guard$.subscribe(() => { + expect(saveRedirectRouteSpy).not.toHaveBeenCalled(); + expect(loginSpy).not.toHaveBeenCalled(); + expect( + checkSavedRedirectRouteAndNavigateSpy + ).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + }); + })); + }); + + describe('autoLoginPartialRoutesGuardWithConfig', () => { + let loginService: LoginService; + let authStateService: AuthStateService; + let storagePersistenceService: StoragePersistenceService; + let configurationService: ConfigurationService; + let autoLoginService: AutoLoginService; + + beforeEach(() => { + authStateService = TestBed.inject(AuthStateService); + loginService = TestBed.inject(LoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + configurationService = TestBed.inject(ConfigurationService); + + spyOn(configurationService, 'getOpenIDConfiguration').and.callFake( + (configId) => of({ configId }) + ); + + autoLoginService = TestBed.inject(AutoLoginService); + }); + + afterEach(() => { + storagePersistenceService.clear({}); + }); + + it('should save current route (empty) and call `login` if not authenticated already', waitForAsync(() => { + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const checkSavedRedirectRouteAndNavigateSpy = spyOn( + autoLoginService, + 'checkSavedRedirectRouteAndNavigate' + ); + const saveRedirectRouteSpy = spyOn( + autoLoginService, + 'saveRedirectRoute' + ); + const loginSpy = spyOn(loginService, 'login'); + + const guard$ = TestBed.runInInjectionContext( + autoLoginPartialRoutesGuardWithConfig('configId1') + ); + + guard$.subscribe(() => { + expect(saveRedirectRouteSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '' + ); + expect(loginSpy).toHaveBeenCalledOnceWith({ configId: 'configId1' }); + expect(checkSavedRedirectRouteAndNavigateSpy).not.toHaveBeenCalled(); + }); + })); + }); + }); +}); diff --git a/src/auto-login/auto-login-partial-routes.guard.ts b/src/auto-login/auto-login-partial-routes.guard.ts new file mode 100644 index 0000000..96144f1 --- /dev/null +++ b/src/auto-login/auto-login-partial-routes.guard.ts @@ -0,0 +1,148 @@ +import { inject, Injectable } from 'injection-js'; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AuthOptions } from '../auth-options'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoginService } from '../login/login.service'; +import { AutoLoginService } from './auto-login.service'; + +@Injectable() +export class AutoLoginPartialRoutesGuard { + private readonly autoLoginService = inject(AutoLoginService); + + private readonly authStateService = inject(AuthStateService); + + private readonly loginService = inject(LoginService); + + private readonly configurationService = inject(ConfigurationService); + + private readonly router = inject(Router); + + canLoad(): Observable { + const url = + this.router + .getCurrentNavigation() + ?.extractedUrl.toString() + .substring(1) ?? ''; + + return checkAuth( + url, + this.configurationService, + this.authStateService, + this.autoLoginService, + this.loginService + ); + } + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + const authOptions: AuthOptions | undefined = route?.data + ? { customParams: route.data } + : undefined; + + return checkAuth( + state.url, + this.configurationService, + this.authStateService, + this.autoLoginService, + this.loginService, + authOptions + ); + } + + canActivateChild( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + const authOptions: AuthOptions | undefined = route?.data + ? { customParams: route.data } + : undefined; + + return checkAuth( + state.url, + this.configurationService, + this.authStateService, + this.autoLoginService, + this.loginService, + authOptions + ); + } +} + +export function autoLoginPartialRoutesGuard( + route?: ActivatedRouteSnapshot, + state?: RouterStateSnapshot, + configId?: string +): Observable { + const configurationService = inject(ConfigurationService); + const authStateService = inject(AuthStateService); + const loginService = inject(LoginService); + const autoLoginService = inject(AutoLoginService); + const router = inject(Router); + const authOptions: AuthOptions | undefined = route?.data + ? { customParams: route.data } + : undefined; + + const url = + router.getCurrentNavigation()?.extractedUrl.toString().substring(1) ?? ''; + + return checkAuth( + url, + configurationService, + authStateService, + autoLoginService, + loginService, + authOptions, + configId + ); +} + +export function autoLoginPartialRoutesGuardWithConfig( + configId: string +): ( + route?: ActivatedRouteSnapshot, + state?: RouterStateSnapshot +) => Observable { + return (route?: ActivatedRouteSnapshot, state?: RouterStateSnapshot) => + autoLoginPartialRoutesGuard(route, state, configId); +} + +function checkAuth( + url: string, + configurationService: ConfigurationService, + authStateService: AuthStateService, + autoLoginService: AutoLoginService, + loginService: LoginService, + authOptions?: AuthOptions, + configId?: string +): Observable { + return configurationService.getOpenIDConfiguration(configId).pipe( + map((configuration) => { + const isAuthenticated = + authStateService.areAuthStorageTokensValid(configuration); + + if (isAuthenticated) { + autoLoginService.checkSavedRedirectRouteAndNavigate(configuration); + } + + if (!isAuthenticated) { + autoLoginService.saveRedirectRoute(configuration, url); + if (authOptions) { + loginService.login(configuration, authOptions); + } else { + loginService.login(configuration); + } + } + + return isAuthenticated; + }) + ); +} diff --git a/src/auto-login/auto-login.service.spec.ts b/src/auto-login/auto-login.service.spec.ts new file mode 100644 index 0000000..b0f685a --- /dev/null +++ b/src/auto-login/auto-login.service.spec.ts @@ -0,0 +1,84 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { AutoLoginService } from './auto-login.service'; + +describe('AutoLoginService ', () => { + let autoLoginService: AutoLoginService; + let storagePersistenceService: StoragePersistenceService; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [AutoLoginService, mockProvider(StoragePersistenceService)], + }); + }); + + beforeEach(() => { + router = TestBed.inject(Router); + autoLoginService = TestBed.inject(AutoLoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + }); + + it('should create', () => { + expect(autoLoginService).toBeTruthy(); + }); + + describe('checkSavedRedirectRouteAndNavigate', () => { + it('if not route is saved, router and delete are not called', () => { + const deleteSpy = spyOn(storagePersistenceService, 'remove'); + const routerSpy = spyOn(router, 'navigateByUrl'); + const readSpy = spyOn(storagePersistenceService, 'read').and.returnValue( + null + ); + + autoLoginService.checkSavedRedirectRouteAndNavigate({ + configId: 'configId1', + }); + + expect(deleteSpy).not.toHaveBeenCalled(); + expect(routerSpy).not.toHaveBeenCalled(); + expect(readSpy).toHaveBeenCalledOnceWith('redirect', { + configId: 'configId1', + }); + }); + + it('if route is saved, router and delete are called', () => { + const deleteSpy = spyOn(storagePersistenceService, 'remove'); + const routerSpy = spyOn(router, 'navigateByUrl'); + const readSpy = spyOn(storagePersistenceService, 'read').and.returnValue( + 'saved-route' + ); + + autoLoginService.checkSavedRedirectRouteAndNavigate({ + configId: 'configId1', + }); + + expect(deleteSpy).toHaveBeenCalledOnceWith('redirect', { + configId: 'configId1', + }); + expect(routerSpy).toHaveBeenCalledOnceWith('saved-route'); + expect(readSpy).toHaveBeenCalledOnceWith('redirect', { + configId: 'configId1', + }); + }); + }); + + describe('saveRedirectRoute', () => { + it('calls storageService with correct params', () => { + const writeSpy = spyOn(storagePersistenceService, 'write'); + + autoLoginService.saveRedirectRoute( + { configId: 'configId1' }, + 'some-route' + ); + + expect(writeSpy).toHaveBeenCalledOnceWith('redirect', 'some-route', { + configId: 'configId1', + }); + }); + }); +}); diff --git a/src/auto-login/auto-login.service.ts b/src/auto-login/auto-login.service.ts new file mode 100644 index 0000000..e58f322 --- /dev/null +++ b/src/auto-login/auto-login.service.ts @@ -0,0 +1,53 @@ +import { inject, Injectable } from 'injection-js'; +import { Router } from '@angular/router'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; + +const STORAGE_KEY = 'redirect'; + +@Injectable() +export class AutoLoginService { + private readonly storageService = inject(StoragePersistenceService); + + private readonly router = inject(Router); + + checkSavedRedirectRouteAndNavigate(config: OpenIdConfiguration | null): void { + if (!config) { + return; + } + const savedRouteForRedirect = this.getStoredRedirectRoute(config); + + if (savedRouteForRedirect != null) { + this.deleteStoredRedirectRoute(config); + this.router.navigateByUrl(savedRouteForRedirect); + } + } + + /** + * Saves the redirect URL to storage. + * + * @param config The OpenId configuration. + * @param url The redirect URL to save. + */ + saveRedirectRoute(config: OpenIdConfiguration | null, url: string): void { + if (!config) { + return; + } + + this.storageService.write(STORAGE_KEY, url, config); + } + + /** + * Gets the stored redirect URL from storage. + */ + private getStoredRedirectRoute(config: OpenIdConfiguration): string { + return this.storageService.read(STORAGE_KEY, config); + } + + /** + * Removes the redirect URL from storage. + */ + private deleteStoredRedirectRoute(config: OpenIdConfiguration): void { + this.storageService.remove(STORAGE_KEY, config); + } +} diff --git a/src/callback/callback.service.spec.ts b/src/callback/callback.service.spec.ts new file mode 100644 index 0000000..75f67b6 --- /dev/null +++ b/src/callback/callback.service.spec.ts @@ -0,0 +1,144 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { UrlService } from '../utils/url/url.service'; +import { CallbackService } from './callback.service'; +import { CodeFlowCallbackService } from './code-flow-callback.service'; +import { ImplicitFlowCallbackService } from './implicit-flow-callback.service'; + +describe('CallbackService ', () => { + let callbackService: CallbackService; + let implicitFlowCallbackService: ImplicitFlowCallbackService; + let codeFlowCallbackService: CodeFlowCallbackService; + let flowHelper: FlowHelper; + let urlService: UrlService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + CallbackService, + mockProvider(UrlService), + FlowHelper, + mockProvider(ImplicitFlowCallbackService), + mockProvider(CodeFlowCallbackService), + ], + }); + }); + + beforeEach(() => { + callbackService = TestBed.inject(CallbackService); + flowHelper = TestBed.inject(FlowHelper); + implicitFlowCallbackService = TestBed.inject(ImplicitFlowCallbackService); + codeFlowCallbackService = TestBed.inject(CodeFlowCallbackService); + urlService = TestBed.inject(UrlService); + }); + + describe('isCallback', () => { + it('calls urlService.isCallbackFromSts with passed url', () => { + const urlServiceSpy = spyOn(urlService, 'isCallbackFromSts'); + + callbackService.isCallback('anyUrl'); + expect(urlServiceSpy).toHaveBeenCalledOnceWith('anyUrl', undefined); + }); + }); + + describe('stsCallback$', () => { + it('is of type Observable', () => { + expect(callbackService.stsCallback$).toBeInstanceOf(Observable); + }); + }); + + describe('handleCallbackAndFireEvents', () => { + it('calls authorizedCallbackWithCode if current flow is code flow', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const authorizedCallbackWithCodeSpy = spyOn( + codeFlowCallbackService, + 'authenticatedCallbackWithCode' + ).and.returnValue(of({} as CallbackContext)); + + callbackService + .handleCallbackAndFireEvents('anyUrl', { configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe(() => { + expect(authorizedCallbackWithCodeSpy).toHaveBeenCalledOnceWith( + 'anyUrl', + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + }); + })); + + it('calls authorizedImplicitFlowCallback without hash if current flow is implicit flow and callbackurl does not include a hash', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowAnyImplicitFlow').and.returnValue(true); + const authorizedCallbackWithCodeSpy = spyOn( + implicitFlowCallbackService, + 'authenticatedImplicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + + callbackService + .handleCallbackAndFireEvents('anyUrl', { configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe(() => { + expect(authorizedCallbackWithCodeSpy).toHaveBeenCalledWith( + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + }); + })); + + it('calls authorizedImplicitFlowCallback with hash if current flow is implicit flow and callbackurl does include a hash', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowAnyImplicitFlow').and.returnValue(true); + const authorizedCallbackWithCodeSpy = spyOn( + implicitFlowCallbackService, + 'authenticatedImplicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + + callbackService + .handleCallbackAndFireEvents( + 'anyUrlWithAHash#some-string', + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ) + .subscribe(() => { + expect(authorizedCallbackWithCodeSpy).toHaveBeenCalledWith( + { configId: 'configId1' }, + [{ configId: 'configId1' }], + 'some-string' + ); + }); + })); + + it('emits callbackinternal no matter which flow it is', waitForAsync(() => { + const callbackSpy = spyOn( + (callbackService as any).stsCallbackInternal$, + 'next' + ); + + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const authenticatedCallbackWithCodeSpy = spyOn( + codeFlowCallbackService, + 'authenticatedCallbackWithCode' + ).and.returnValue(of({} as CallbackContext)); + + callbackService + .handleCallbackAndFireEvents('anyUrl', { configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe(() => { + expect(authenticatedCallbackWithCodeSpy).toHaveBeenCalledOnceWith( + 'anyUrl', + { configId: 'configId1' }, + [{ configId: 'configId1' }] + ); + expect(callbackSpy).toHaveBeenCalled(); + }); + })); + }); +}); diff --git a/src/callback/callback.service.ts b/src/callback/callback.service.ts new file mode 100644 index 0000000..7af6984 --- /dev/null +++ b/src/callback/callback.service.ts @@ -0,0 +1,73 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, Subject } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { UrlService } from '../utils/url/url.service'; +import { CodeFlowCallbackService } from './code-flow-callback.service'; +import { ImplicitFlowCallbackService } from './implicit-flow-callback.service'; + +@Injectable() +export class CallbackService { + private readonly urlService = inject(UrlService); + + private readonly flowHelper = inject(FlowHelper); + + private readonly implicitFlowCallbackService = inject( + ImplicitFlowCallbackService + ); + + private readonly codeFlowCallbackService = inject(CodeFlowCallbackService); + + private readonly stsCallbackInternal$ = new Subject(); + + get stsCallback$(): Observable { + return this.stsCallbackInternal$.asObservable(); + } + + isCallback(currentUrl: string, config?: OpenIdConfiguration): boolean { + if (!currentUrl) { + return false; + } + + return this.urlService.isCallbackFromSts(currentUrl, config); + } + + handleCallbackAndFireEvents( + currentCallbackUrl: string, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + let callback$: Observable = new Observable(); + + if (this.flowHelper.isCurrentFlowCodeFlow(config)) { + callback$ = this.codeFlowCallbackService.authenticatedCallbackWithCode( + currentCallbackUrl, + config, + allConfigs + ); + } else if (this.flowHelper.isCurrentFlowAnyImplicitFlow(config)) { + if (currentCallbackUrl?.includes('#')) { + const hash = currentCallbackUrl.substring( + currentCallbackUrl.indexOf('#') + 1 + ); + + callback$ = + this.implicitFlowCallbackService.authenticatedImplicitFlowCallback( + config, + allConfigs, + hash + ); + } else { + callback$ = + this.implicitFlowCallbackService.authenticatedImplicitFlowCallback( + config, + allConfigs + ); + } + } + + return callback$.pipe(tap(() => this.stsCallbackInternal$.next())); + } +} diff --git a/src/callback/code-flow-callback.service.spec.ts b/src/callback/code-flow-callback.service.spec.ts new file mode 100644 index 0000000..3e9996c --- /dev/null +++ b/src/callback/code-flow-callback.service.spec.ts @@ -0,0 +1,198 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { CodeFlowCallbackService } from './code-flow-callback.service'; +import { IntervalService } from './interval.service'; + +describe('CodeFlowCallbackService ', () => { + let codeFlowCallbackService: CodeFlowCallbackService; + let intervalService: IntervalService; + let flowsService: FlowsService; + let flowsDataService: FlowsDataService; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [ + CodeFlowCallbackService, + mockProvider(FlowsService), + mockProvider(FlowsDataService), + mockProvider(IntervalService), + ], + }); + }); + + beforeEach(() => { + codeFlowCallbackService = TestBed.inject(CodeFlowCallbackService); + intervalService = TestBed.inject(IntervalService); + flowsDataService = TestBed.inject(FlowsDataService); + flowsService = TestBed.inject(FlowsService); + router = TestBed.inject(Router); + }); + + it('should create', () => { + expect(codeFlowCallbackService).toBeTruthy(); + }); + + describe('authenticatedCallbackWithCode', () => { + it('calls flowsService.processCodeFlowCallback with correct url', () => { + const spy = spyOn( + flowsService, + 'processCodeFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + //spyOn(configurationProvider, 'getOpenIDConfiguration').and.returnValue({ triggerAuthorizationResultEvent: true }); + + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: true, + }; + + codeFlowCallbackService.authenticatedCallbackWithCode( + 'some-url1', + config, + [config] + ); + expect(spy).toHaveBeenCalledOnceWith('some-url1', config, [config]); + }); + + it('does only call resetCodeFlowInProgress if triggerAuthorizationResultEvent is true and isRenewProcess is true', waitForAsync(() => { + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: '', + }; + const spy = spyOn( + flowsService, + 'processCodeFlowCallback' + ).and.returnValue(of(callbackContext)); + const flowsDataSpy = spyOn(flowsDataService, 'resetCodeFlowInProgress'); + const routerSpy = spyOn(router, 'navigateByUrl'); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: true, + }; + + codeFlowCallbackService + .authenticatedCallbackWithCode('some-url2', config, [config]) + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-url2', config, [config]); + expect(routerSpy).not.toHaveBeenCalled(); + expect(flowsDataSpy).toHaveBeenCalled(); + }); + })); + + it('calls router and resetCodeFlowInProgress if triggerAuthorizationResultEvent is false and isRenewProcess is false', waitForAsync(() => { + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: null, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: '', + }; + const spy = spyOn( + flowsService, + 'processCodeFlowCallback' + ).and.returnValue(of(callbackContext)); + const flowsDataSpy = spyOn(flowsDataService, 'resetCodeFlowInProgress'); + const routerSpy = spyOn(router, 'navigateByUrl'); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + postLoginRoute: 'postLoginRoute', + }; + + codeFlowCallbackService + .authenticatedCallbackWithCode('some-url3', config, [config]) + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-url3', config, [config]); + expect(routerSpy).toHaveBeenCalledOnceWith('postLoginRoute'); + expect(flowsDataSpy).toHaveBeenCalled(); + }); + })); + + it('resetSilentRenewRunning, resetCodeFlowInProgress and stopPeriodicallTokenCheck in case of error', waitForAsync(() => { + spyOn(flowsService, 'processCodeFlowCallback').and.returnValue( + throwError(() => new Error('error')) + ); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const resetCodeFlowInProgressSpy = spyOn( + flowsDataService, + 'resetCodeFlowInProgress' + ); + const stopPeriodicallTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + postLoginRoute: 'postLoginRoute', + }; + + codeFlowCallbackService + .authenticatedCallbackWithCode('some-url4', config, [config]) + .subscribe({ + error: (err) => { + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + expect(resetCodeFlowInProgressSpy).toHaveBeenCalled(); + expect(stopPeriodicallTokenCheckSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it(`navigates to unauthorizedRoute in case of error and in case of error and + triggerAuthorizationResultEvent is false`, waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(flowsService, 'processCodeFlowCallback').and.returnValue( + throwError(() => new Error('error')) + ); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const stopPeriodicallTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + const routerSpy = spyOn(router, 'navigateByUrl'); + + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + unauthorizedRoute: 'unauthorizedRoute', + }; + + codeFlowCallbackService + .authenticatedCallbackWithCode('some-url5', config, [config]) + .subscribe({ + error: (err) => { + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + expect(stopPeriodicallTokenCheckSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + expect(routerSpy).toHaveBeenCalledOnceWith('unauthorizedRoute'); + }, + }); + })); + }); +}); diff --git a/src/callback/code-flow-callback.service.ts b/src/callback/code-flow-callback.service.ts new file mode 100644 index 0000000..21374f4 --- /dev/null +++ b/src/callback/code-flow-callback.service.ts @@ -0,0 +1,56 @@ +import { inject, Injectable } from 'injection-js'; +import { Router } from '@angular/router'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { IntervalService } from './interval.service'; + +@Injectable() +export class CodeFlowCallbackService { + private readonly flowsService = inject(FlowsService); + + private readonly router = inject(Router); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly intervalService = inject(IntervalService); + + authenticatedCallbackWithCode( + urlToCheck: string, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const isRenewProcess = this.flowsDataService.isSilentRenewRunning(config); + const { triggerAuthorizationResultEvent } = config; + + const postLoginRoute = config.postLoginRoute || '/'; + const unauthorizedRoute = config.unauthorizedRoute || '/'; + + return this.flowsService + .processCodeFlowCallback(urlToCheck, config, allConfigs) + .pipe( + tap((callbackContext) => { + this.flowsDataService.resetCodeFlowInProgress(config); + if ( + !triggerAuthorizationResultEvent && + !callbackContext.isRenewProcess + ) { + this.router.navigateByUrl(postLoginRoute); + } + }), + catchError((error) => { + this.flowsDataService.resetSilentRenewRunning(config); + this.flowsDataService.resetCodeFlowInProgress(config); + this.intervalService.stopPeriodicTokenCheck(); + if (!triggerAuthorizationResultEvent && !isRenewProcess) { + this.router.navigateByUrl(unauthorizedRoute); + } + + return throwError(() => new Error(error)); + }) + ); + } +} diff --git a/src/callback/implicit-flow-callback.service.spec.ts b/src/callback/implicit-flow-callback.service.spec.ts new file mode 100644 index 0000000..830de5e --- /dev/null +++ b/src/callback/implicit-flow-callback.service.spec.ts @@ -0,0 +1,185 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { ImplicitFlowCallbackService } from './implicit-flow-callback.service'; +import { IntervalService } from './interval.service'; + +describe('ImplicitFlowCallbackService ', () => { + let implicitFlowCallbackService: ImplicitFlowCallbackService; + let intervalService: IntervalService; + let flowsService: FlowsService; + let flowsDataService: FlowsDataService; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + providers: [ + mockProvider(FlowsService), + mockProvider(FlowsDataService), + mockProvider(IntervalService), + ], + }); + }); + + beforeEach(() => { + implicitFlowCallbackService = TestBed.inject(ImplicitFlowCallbackService); + intervalService = TestBed.inject(IntervalService); + flowsDataService = TestBed.inject(FlowsDataService); + flowsService = TestBed.inject(FlowsService); + router = TestBed.inject(Router); + }); + + it('should create', () => { + expect(implicitFlowCallbackService).toBeTruthy(); + }); + + describe('authorizedImplicitFlowCallback', () => { + it('calls flowsService.processImplicitFlowCallback with hash if given', () => { + const spy = spyOn( + flowsService, + 'processImplicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: true, + }; + + implicitFlowCallbackService.authenticatedImplicitFlowCallback( + config, + [config], + 'some-hash' + ); + + expect(spy).toHaveBeenCalledOnceWith(config, [config], 'some-hash'); + }); + + it('does nothing if triggerAuthorizationResultEvent is true and isRenewProcess is true', waitForAsync(() => { + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: '', + }; + const spy = spyOn( + flowsService, + 'processImplicitFlowCallback' + ).and.returnValue(of(callbackContext)); + const routerSpy = spyOn(router, 'navigateByUrl'); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: true, + }; + + implicitFlowCallbackService + .authenticatedImplicitFlowCallback(config, [config], 'some-hash') + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], 'some-hash'); + expect(routerSpy).not.toHaveBeenCalled(); + }); + })); + + it('calls router if triggerAuthorizationResultEvent is false and isRenewProcess is false', waitForAsync(() => { + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: null, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: '', + }; + const spy = spyOn( + flowsService, + 'processImplicitFlowCallback' + ).and.returnValue(of(callbackContext)); + const routerSpy = spyOn(router, 'navigateByUrl'); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + postLoginRoute: 'postLoginRoute', + }; + + implicitFlowCallbackService + .authenticatedImplicitFlowCallback(config, [config], 'some-hash') + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], 'some-hash'); + expect(routerSpy).toHaveBeenCalledOnceWith('postLoginRoute'); + }); + })); + + it('resetSilentRenewRunning and stopPeriodicallyTokenCheck in case of error', waitForAsync(() => { + spyOn(flowsService, 'processImplicitFlowCallback').and.returnValue( + throwError(() => new Error('error')) + ); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const stopPeriodicallyTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + postLoginRoute: 'postLoginRoute', + }; + + implicitFlowCallbackService + .authenticatedImplicitFlowCallback(config, [config], 'some-hash') + .subscribe({ + error: (err) => { + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + expect(stopPeriodicallyTokenCheckSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it(`navigates to unauthorizedRoute in case of error and in case of error and + triggerAuthorizationResultEvent is false`, waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(flowsService, 'processImplicitFlowCallback').and.returnValue( + throwError(() => new Error('error')) + ); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const stopPeriodicallTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + const routerSpy = spyOn(router, 'navigateByUrl'); + const config = { + configId: 'configId1', + triggerAuthorizationResultEvent: false, + unauthorizedRoute: 'unauthorizedRoute', + }; + + implicitFlowCallbackService + .authenticatedImplicitFlowCallback(config, [config], 'some-hash') + .subscribe({ + error: (err) => { + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + expect(stopPeriodicallTokenCheckSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + expect(routerSpy).toHaveBeenCalledOnceWith('unauthorizedRoute'); + }, + }); + })); + }); +}); diff --git a/src/callback/implicit-flow-callback.service.ts b/src/callback/implicit-flow-callback.service.ts new file mode 100644 index 0000000..f52de47 --- /dev/null +++ b/src/callback/implicit-flow-callback.service.ts @@ -0,0 +1,55 @@ +import { inject, Injectable } from 'injection-js'; +import { Router } from '@angular/router'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { IntervalService } from './interval.service'; + +@Injectable() +export class ImplicitFlowCallbackService { + private readonly flowsService = inject(FlowsService); + + private readonly router = inject(Router); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly intervalService = inject(IntervalService); + + authenticatedImplicitFlowCallback( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + hash?: string + ): Observable { + const isRenewProcess = this.flowsDataService.isSilentRenewRunning(config); + const triggerAuthorizationResultEvent = Boolean( + config.triggerAuthorizationResultEvent + ); + const postLoginRoute = config.postLoginRoute ?? ''; + const unauthorizedRoute = config.unauthorizedRoute ?? ''; + + return this.flowsService + .processImplicitFlowCallback(config, allConfigs, hash) + .pipe( + tap((callbackContext) => { + if ( + !triggerAuthorizationResultEvent && + !callbackContext.isRenewProcess + ) { + this.router.navigateByUrl(postLoginRoute); + } + }), + catchError((error) => { + this.flowsDataService.resetSilentRenewRunning(config); + this.intervalService.stopPeriodicTokenCheck(); + if (!triggerAuthorizationResultEvent && !isRenewProcess) { + this.router.navigateByUrl(unauthorizedRoute); + } + + return throwError(() => new Error(error)); + }) + ); + } +} diff --git a/src/callback/interval.service.spec.ts b/src/callback/interval.service.spec.ts new file mode 100644 index 0000000..2e124f5 --- /dev/null +++ b/src/callback/interval.service.spec.ts @@ -0,0 +1,76 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Subscription } from 'rxjs'; +import { IntervalService } from './interval.service'; + +describe('IntervalService', () => { + let intervalService: IntervalService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: Document, + useValue: { + defaultView: { + setInterval: window.setInterval, + }, + }, + }, + ], + }); + }); + + beforeEach(() => { + intervalService = TestBed.inject(IntervalService); + }); + + it('should create', () => { + expect(intervalService).toBeTruthy(); + }); + + describe('stopPeriodicTokenCheck', () => { + it('calls unsubscribe and sets to null', () => { + intervalService.runTokenValidationRunning = new Subscription(); + const spy = spyOn( + intervalService.runTokenValidationRunning, + 'unsubscribe' + ); + + intervalService.stopPeriodicTokenCheck(); + + expect(spy).toHaveBeenCalled(); + expect(intervalService.runTokenValidationRunning).toBeNull(); + }); + + it('does nothing if `runTokenValidationRunning` is null', () => { + intervalService.runTokenValidationRunning = new Subscription(); + const spy = spyOn( + intervalService.runTokenValidationRunning, + 'unsubscribe' + ); + + intervalService.runTokenValidationRunning = null; + intervalService.stopPeriodicTokenCheck(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('startPeriodicTokenCheck', () => { + it('starts check after correct milliseconds', fakeAsync(() => { + const periodicCheck = intervalService.startPeriodicTokenCheck(0.5); + const spy = jasmine.createSpy(); + const sub = periodicCheck.subscribe(() => { + spy(); + }); + + tick(500); + expect(spy).toHaveBeenCalledTimes(1); + + tick(500); + expect(spy).toHaveBeenCalledTimes(2); + + sub.unsubscribe(); + })); + }); +}); diff --git a/src/callback/interval.service.ts b/src/callback/interval.service.ts new file mode 100644 index 0000000..c8bbd6d --- /dev/null +++ b/src/callback/interval.service.ts @@ -0,0 +1,42 @@ +import { Injectable, NgZone, inject } from 'injection-js'; +import { Observable, Subscription } from 'rxjs'; +import { DOCUMENT } from '../../dom'; + +@Injectable() +export class IntervalService { + private readonly zone = inject(NgZone); + + private readonly document = inject(DOCUMENT); + + runTokenValidationRunning: Subscription | null = null; + + isTokenValidationRunning(): boolean { + return Boolean(this.runTokenValidationRunning); + } + + stopPeriodicTokenCheck(): void { + if (this.runTokenValidationRunning) { + this.runTokenValidationRunning.unsubscribe(); + this.runTokenValidationRunning = null; + } + } + + startPeriodicTokenCheck(repeatAfterSeconds: number): Observable { + const millisecondsDelayBetweenTokenCheck = repeatAfterSeconds * 1000; + + return new Observable((subscriber) => { + let intervalId: number | undefined; + + this.zone.runOutsideAngular(() => { + intervalId = this.document?.defaultView?.setInterval( + () => this.zone.run(() => subscriber.next()), + millisecondsDelayBetweenTokenCheck + ); + }); + + return (): void => { + clearInterval(intervalId); + }; + }); + } +} diff --git a/src/callback/periodically-token-check.service.spec.ts b/src/callback/periodically-token-check.service.spec.ts new file mode 100644 index 0000000..93e470e --- /dev/null +++ b/src/callback/periodically-token-check.service.spec.ts @@ -0,0 +1,432 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ConfigurationService } from '../config/config.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { RefreshSessionIframeService } from '../iframe/refresh-session-iframe.service'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { IntervalService } from './interval.service'; +import { PeriodicallyTokenCheckService } from './periodically-token-check.service'; +import { RefreshSessionRefreshTokenService } from './refresh-session-refresh-token.service'; + +describe('PeriodicallyTokenCheckService', () => { + let periodicallyTokenCheckService: PeriodicallyTokenCheckService; + let intervalService: IntervalService; + let flowsDataService: FlowsDataService; + let flowHelper: FlowHelper; + let authStateService: AuthStateService; + let refreshSessionRefreshTokenService: RefreshSessionRefreshTokenService; + let userService: UserService; + let storagePersistenceService: StoragePersistenceService; + let resetAuthDataService: ResetAuthDataService; + let configurationService: ConfigurationService; + let publicEventsService: PublicEventsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + mockProvider(ResetAuthDataService), + FlowHelper, + mockProvider(FlowsDataService), + mockProvider(LoggerService), + mockProvider(UserService), + mockProvider(AuthStateService), + mockProvider(RefreshSessionIframeService), + mockProvider(RefreshSessionRefreshTokenService), + mockProvider(IntervalService), + mockProvider(StoragePersistenceService), + mockProvider(PublicEventsService), + mockProvider(ConfigurationService), + ], + }); + }); + + beforeEach(() => { + periodicallyTokenCheckService = TestBed.inject( + PeriodicallyTokenCheckService + ); + intervalService = TestBed.inject(IntervalService); + flowsDataService = TestBed.inject(FlowsDataService); + flowHelper = TestBed.inject(FlowHelper); + authStateService = TestBed.inject(AuthStateService); + refreshSessionRefreshTokenService = TestBed.inject( + RefreshSessionRefreshTokenService + ); + userService = TestBed.inject(UserService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + publicEventsService = TestBed.inject(PublicEventsService); + configurationService = TestBed.inject(ConfigurationService); + + spyOn(intervalService, 'startPeriodicTokenCheck').and.returnValue(of(null)); + }); + + afterEach(() => { + if (!!intervalService.runTokenValidationRunning?.unsubscribe) { + intervalService.runTokenValidationRunning.unsubscribe(); + intervalService.runTokenValidationRunning = null; + } + }); + + it('should create', () => { + expect(periodicallyTokenCheckService).toBeTruthy(); + }); + + describe('startTokenValidationPeriodically', () => { + it('returns if no config has silentrenew enabled', waitForAsync(() => { + const configs = [ + { silentRenew: false, configId: 'configId1' }, + { silentRenew: false, configId: 'configId2' }, + ]; + + const result = + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + expect(result).toBeUndefined(); + })); + + it('returns if runTokenValidationRunning', waitForAsync(() => { + const configs = [{ silentRenew: true, configId: 'configId1' }]; + + spyOn(intervalService, 'isTokenValidationRunning').and.returnValue(true); + + const result = + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + expect(result).toBeUndefined(); + })); + + it('interval calls resetSilentRenewRunning when current flow is CodeFlowWithRefreshTokens', fakeAsync(() => { + const configs = [ + { silentRenew: true, configId: 'configId1', tokenRefreshInSeconds: 1 }, + ]; + + spyOn( + periodicallyTokenCheckService as any, + 'shouldStartPeriodicallyCheckForConfig' + ).and.returnValue(true); + const isCurrentFlowCodeFlowWithRefreshTokensSpy = spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + + spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(configs[0]) + ); + + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + tick(1000); + + intervalService.runTokenValidationRunning?.unsubscribe(); + intervalService.runTokenValidationRunning = null; + expect(isCurrentFlowCodeFlowWithRefreshTokensSpy).toHaveBeenCalled(); + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + })); + + it('interval calls resetSilentRenewRunning in case of error when current flow is CodeFlowWithRefreshTokens', fakeAsync(() => { + const configs = [ + { silentRenew: true, configId: 'configId1', tokenRefreshInSeconds: 1 }, + ]; + + spyOn( + periodicallyTokenCheckService as any, + 'shouldStartPeriodicallyCheckForConfig' + ).and.returnValue(true); + const resetSilentRenewRunning = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(throwError(() => new Error('error'))); + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(configs[0]) + ); + + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + tick(1000); + + expect( + periodicallyTokenCheckService.startTokenValidationPeriodically + ).toThrowError(); + expect(resetSilentRenewRunning).toHaveBeenCalledOnceWith(configs[0]); + })); + + it('interval throws silent renew failed event with data in case of an error', fakeAsync(() => { + const configs = [ + { silentRenew: true, configId: 'configId1', tokenRefreshInSeconds: 1 }, + ]; + + spyOn( + periodicallyTokenCheckService as any, + 'shouldStartPeriodicallyCheckForConfig' + ).and.returnValue(true); + spyOn(flowsDataService, 'resetSilentRenewRunning'); + const publicEventsServiceSpy = spyOn(publicEventsService, 'fireEvent'); + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(throwError(() => new Error('error'))); + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(configs[0]) + ); + + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + tick(1000); + + expect( + periodicallyTokenCheckService.startTokenValidationPeriodically + ).toThrowError(); + expect(publicEventsServiceSpy.calls.allArgs()).toEqual([ + [EventTypes.SilentRenewStarted], + [EventTypes.SilentRenewFailed, new Error('error')], + ]); + })); + + it('calls resetAuthorizationData and returns if no silent renew is configured', fakeAsync(() => { + const configs = [ + { silentRenew: true, configId: 'configId1', tokenRefreshInSeconds: 1 }, + ]; + + spyOn( + periodicallyTokenCheckService as any, + 'shouldStartPeriodicallyCheckForConfig' + ).and.returnValue(true); + + const configSpy = spyOn(configurationService, 'getOpenIDConfiguration'); + const configWithoutSilentRenew = { + silentRenew: false, + configId: 'configId1', + tokenRefreshInSeconds: 1, + }; + const configWithoutSilentRenew$ = of(configWithoutSilentRenew); + + configSpy.and.returnValue(configWithoutSilentRenew$); + + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + tick(1000); + intervalService.runTokenValidationRunning?.unsubscribe(); + intervalService.runTokenValidationRunning = null; + + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1); + expect(resetAuthorizationDataSpy).toHaveBeenCalledOnceWith( + configWithoutSilentRenew, + configs + ); + })); + + it('calls refreshSessionWithRefreshTokens if current flow is Code flow with refresh tokens', fakeAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + periodicallyTokenCheckService as any, + 'shouldStartPeriodicallyCheckForConfig' + ).and.returnValue(true); + spyOn(storagePersistenceService, 'read').and.returnValue({}); + const configs = [ + { configId: 'configId1', silentRenew: true, tokenRefreshInSeconds: 1 }, + ]; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(configs[0] as OpenIdConfiguration) + ); + const refreshSessionWithRefreshTokensSpy = spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + + periodicallyTokenCheckService.startTokenValidationPeriodically( + configs, + configs[0] + ); + + tick(1000); + + intervalService.runTokenValidationRunning?.unsubscribe(); + intervalService.runTokenValidationRunning = null; + expect(refreshSessionWithRefreshTokensSpy).toHaveBeenCalled(); + })); + }); + + describe('shouldStartPeriodicallyCheckForConfig', () => { + it('returns false when there is no IdToken', () => { + spyOn(authStateService, 'getIdToken').and.returnValue(''); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeFalse(); + }); + + it('returns false when silent renew is running', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeFalse(); + }); + + it('returns false when code flow is in progress', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(flowsDataService, 'isCodeFlowInProgress').and.returnValue(true); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeFalse(); + }); + + it('returns false when there is no userdata from the store', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + spyOn(userService, 'getUserDataFromStore').and.returnValue(null); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeFalse(); + }); + + it('returns true when there is userDataFromStore, silentrenew is not running and there is an idtoken', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + + spyOn( + authStateService, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(true); + spyOn( + authStateService, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(true); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeTrue(); + }); + + it('returns false if tokens are not expired', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + spyOn( + authStateService, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(false); + spyOn( + authStateService, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(false); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeFalse(); + }); + + it('returns true if tokens are expired', () => { + spyOn(authStateService, 'getIdToken').and.returnValue('idToken'); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn(userService, 'getUserDataFromStore').and.returnValue( + 'some-userdata' + ); + + spyOn( + authStateService, + 'hasIdTokenExpiredAndRenewCheckIsEnabled' + ).and.returnValue(true); + spyOn( + authStateService, + 'hasAccessTokenExpiredIfExpiryExists' + ).and.returnValue(true); + + const result = ( + periodicallyTokenCheckService as any + ).shouldStartPeriodicallyCheckForConfig({ configId: 'configId1' }); + + expect(result).toBeTrue(); + }); + }); +}); diff --git a/src/callback/periodically-token-check.service.ts b/src/callback/periodically-token-check.service.ts new file mode 100644 index 0000000..a5691da --- /dev/null +++ b/src/callback/periodically-token-check.service.ts @@ -0,0 +1,258 @@ +import { inject, Injectable } from 'injection-js'; +import { forkJoin, Observable, of, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ConfigurationService } from '../config/config.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { RefreshSessionIframeService } from '../iframe/refresh-session-iframe.service'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { IntervalService } from './interval.service'; +import { RefreshSessionRefreshTokenService } from './refresh-session-refresh-token.service'; + +@Injectable() +export class PeriodicallyTokenCheckService { + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly flowHelper = inject(FlowHelper); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly loggerService = inject(LoggerService); + + private readonly userService = inject(UserService); + + private readonly authStateService = inject(AuthStateService); + + private readonly refreshSessionIframeService = inject( + RefreshSessionIframeService + ); + + private readonly refreshSessionRefreshTokenService = inject( + RefreshSessionRefreshTokenService + ); + + private readonly intervalService = inject(IntervalService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly publicEventsService = inject(PublicEventsService); + + private readonly configurationService = inject(ConfigurationService); + + startTokenValidationPeriodically( + allConfigs: OpenIdConfiguration[], + currentConfig: OpenIdConfiguration + ): void { + const configsWithSilentRenewEnabled = + this.getConfigsWithSilentRenewEnabled(allConfigs); + + if (configsWithSilentRenewEnabled.length <= 0) { + return; + } + + if (this.intervalService.isTokenValidationRunning()) { + return; + } + + const refreshTimeInSeconds = this.getSmallestRefreshTimeFromConfigs( + configsWithSilentRenewEnabled + ); + const periodicallyCheck$ = this.intervalService + .startPeriodicTokenCheck(refreshTimeInSeconds) + .pipe( + switchMap(() => { + const objectWithConfigIdsAndRefreshEvent: { + [id: string]: Observable; + } = {}; + + configsWithSilentRenewEnabled.forEach((config) => { + const identifier = config.configId as string; + const refreshEvent = this.getRefreshEvent(config, allConfigs); + + objectWithConfigIdsAndRefreshEvent[identifier] = refreshEvent; + }); + + return forkJoin(objectWithConfigIdsAndRefreshEvent); + }) + ); + + this.intervalService.runTokenValidationRunning = periodicallyCheck$ + .pipe(catchError((error) => throwError(() => new Error(error)))) + .subscribe({ + next: (objectWithConfigIds) => { + for (const [configId, _] of Object.entries(objectWithConfigIds)) { + this.configurationService + .getOpenIDConfiguration(configId) + .subscribe((config) => { + this.loggerService.logDebug( + config, + 'silent renew, periodic check finished!' + ); + + if ( + this.flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config) + ) { + this.flowsDataService.resetSilentRenewRunning(config); + } + }); + } + }, + error: (error) => { + this.loggerService.logError( + currentConfig, + 'silent renew failed!', + error + ); + }, + }); + } + + private getRefreshEvent( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const shouldStartRefreshEvent = + this.shouldStartPeriodicallyCheckForConfig(config); + + if (!shouldStartRefreshEvent) { + return of(null); + } + + const refreshEvent$ = this.createRefreshEventForConfig(config, allConfigs); + + this.publicEventsService.fireEvent(EventTypes.SilentRenewStarted); + + return refreshEvent$.pipe( + catchError((error) => { + this.loggerService.logError(config, 'silent renew failed!', error); + this.publicEventsService.fireEvent(EventTypes.SilentRenewFailed, error); + this.flowsDataService.resetSilentRenewRunning(config); + + return throwError(() => new Error(error)); + }) + ); + } + + private getSmallestRefreshTimeFromConfigs( + configsWithSilentRenewEnabled: OpenIdConfiguration[] + ): number { + const result = configsWithSilentRenewEnabled.reduce((prev, curr) => + (prev.tokenRefreshInSeconds ?? 0) < (curr.tokenRefreshInSeconds ?? 0) + ? prev + : curr + ); + + return result.tokenRefreshInSeconds ?? 0; + } + + private getConfigsWithSilentRenewEnabled( + allConfigs: OpenIdConfiguration[] + ): OpenIdConfiguration[] { + return allConfigs.filter((x) => x.silentRenew); + } + + private createRefreshEventForConfig( + configuration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + this.loggerService.logDebug(configuration, 'starting silent renew...'); + + return this.configurationService + .getOpenIDConfiguration(configuration.configId) + .pipe( + switchMap((config) => { + if (!config?.silentRenew) { + this.resetAuthDataService.resetAuthorizationData( + config, + allConfigs + ); + + return of(null); + } + + this.flowsDataService.setSilentRenewRunning(config); + + if (this.flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config)) { + // Retrieve Dynamically Set Custom Params for refresh body + const customParamsRefresh: { + [key: string]: string | number | boolean; + } = + this.storagePersistenceService.read( + 'storageCustomParamsRefresh', + config + ) || {}; + + const { customParamsRefreshTokenRequest } = config; + + const mergedParams = { + ...customParamsRefreshTokenRequest, + ...customParamsRefresh, + }; + + // Refresh Session using Refresh tokens + return this.refreshSessionRefreshTokenService.refreshSessionWithRefreshTokens( + config, + allConfigs, + mergedParams + ); + } + + // Retrieve Dynamically Set Custom Params + const customParams: { [key: string]: string | number | boolean } = + this.storagePersistenceService.read( + 'storageCustomParamsAuthRequest', + config + ); + + return this.refreshSessionIframeService.refreshSessionWithIframe( + config, + allConfigs, + customParams + ); + }) + ); + } + + private shouldStartPeriodicallyCheckForConfig( + config: OpenIdConfiguration + ): boolean { + const idToken = this.authStateService.getIdToken(config); + const isSilentRenewRunning = + this.flowsDataService.isSilentRenewRunning(config); + const isCodeFlowInProgress = + this.flowsDataService.isCodeFlowInProgress(config); + const userDataFromStore = this.userService.getUserDataFromStore(config); + + this.loggerService.logDebug( + config, + `Checking: silentRenewRunning: ${isSilentRenewRunning}, isCodeFlowInProgress: ${isCodeFlowInProgress} - has idToken: ${!!idToken} - has userData: ${!!userDataFromStore}` + ); + + const shouldBeExecuted = + !!userDataFromStore && + !isSilentRenewRunning && + !!idToken && + !isCodeFlowInProgress; + + if (!shouldBeExecuted) { + return false; + } + + const idTokenExpired = + this.authStateService.hasIdTokenExpiredAndRenewCheckIsEnabled(config); + const accessTokenExpired = + this.authStateService.hasAccessTokenExpiredIfExpiryExists(config); + + return idTokenExpired || accessTokenExpired; + } +} diff --git a/src/callback/refresh-session-refresh-token.service.spec.ts b/src/callback/refresh-session-refresh-token.service.spec.ts new file mode 100644 index 0000000..6aab19d --- /dev/null +++ b/src/callback/refresh-session-refresh-token.service.spec.ts @@ -0,0 +1,101 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsService } from '../flows/flows.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { LoggerService } from '../logging/logger.service'; +import { IntervalService } from './interval.service'; +import { RefreshSessionRefreshTokenService } from './refresh-session-refresh-token.service'; + +describe('RefreshSessionRefreshTokenService', () => { + let refreshSessionRefreshTokenService: RefreshSessionRefreshTokenService; + let intervalService: IntervalService; + let resetAuthDataService: ResetAuthDataService; + let flowsService: FlowsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + RefreshSessionRefreshTokenService, + mockProvider(LoggerService), + mockProvider(FlowsService), + mockProvider(ResetAuthDataService), + mockProvider(IntervalService), + ], + }); + }); + + beforeEach(() => { + flowsService = TestBed.inject(FlowsService); + refreshSessionRefreshTokenService = TestBed.inject( + RefreshSessionRefreshTokenService + ); + intervalService = TestBed.inject(IntervalService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + }); + + it('should create', () => { + expect(refreshSessionRefreshTokenService).toBeTruthy(); + }); + + describe('refreshSessionWithRefreshTokens', () => { + it('calls flowsService.processRefreshToken()', waitForAsync(() => { + const spy = spyOn(flowsService, 'processRefreshToken').and.returnValue( + of({} as CallbackContext) + ); + + refreshSessionRefreshTokenService + .refreshSessionWithRefreshTokens({ configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe(() => { + expect(spy).toHaveBeenCalled(); + }); + })); + + it('resetAuthorizationData in case of error', waitForAsync(() => { + spyOn(flowsService, 'processRefreshToken').and.returnValue( + throwError(() => new Error('error')) + ); + const resetSilentRenewRunningSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + + refreshSessionRefreshTokenService + .refreshSessionWithRefreshTokens({ configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe({ + error: (err) => { + expect(resetSilentRenewRunningSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it('finalize with stopPeriodicTokenCheck in case of error', fakeAsync(() => { + spyOn(flowsService, 'processRefreshToken').and.returnValue( + throwError(() => new Error('error')) + ); + const stopPeriodicallyTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + + refreshSessionRefreshTokenService + .refreshSessionWithRefreshTokens({ configId: 'configId1' }, [ + { configId: 'configId1' }, + ]) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + tick(); + expect(stopPeriodicallyTokenCheckSpy).toHaveBeenCalled(); + })); + }); +}); diff --git a/src/callback/refresh-session-refresh-token.service.ts b/src/callback/refresh-session-refresh-token.service.ts new file mode 100644 index 0000000..56cd31c --- /dev/null +++ b/src/callback/refresh-session-refresh-token.service.ts @@ -0,0 +1,44 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, throwError } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsService } from '../flows/flows.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { LoggerService } from '../logging/logger.service'; +import { IntervalService } from './interval.service'; + +@Injectable() +export class RefreshSessionRefreshTokenService { + private readonly loggerService = inject(LoggerService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly flowsService = inject(FlowsService); + + private readonly intervalService = inject(IntervalService); + + refreshSessionWithRefreshTokens( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + customParamsRefresh?: { [key: string]: string | number | boolean } + ): Observable { + this.loggerService.logDebug(config, 'BEGIN refresh session Authorize'); + let refreshTokenFailed = false; + + return this.flowsService + .processRefreshToken(config, allConfigs, customParamsRefresh) + .pipe( + catchError((error) => { + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + refreshTokenFailed = true; + + return throwError(() => new Error(error)); + }), + finalize( + () => + refreshTokenFailed && this.intervalService.stopPeriodicTokenCheck() + ) + ); + } +} diff --git a/src/callback/refresh-session.service.spec.ts b/src/callback/refresh-session.service.spec.ts new file mode 100644 index 0000000..4cc72ec --- /dev/null +++ b/src/callback/refresh-session.service.spec.ts @@ -0,0 +1,667 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { AuthWellKnownService } from '../config/auth-well-known/auth-well-known.service'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { RefreshSessionIframeService } from '../iframe/refresh-session-iframe.service'; +import { SilentRenewService } from '../iframe/silent-renew.service'; +import { LoggerService } from '../logging/logger.service'; +import { LoginResponse } from '../login/login-response'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { RefreshSessionRefreshTokenService } from './refresh-session-refresh-token.service'; +import { + MAX_RETRY_ATTEMPTS, + RefreshSessionService, +} from './refresh-session.service'; + +describe('RefreshSessionService ', () => { + let refreshSessionService: RefreshSessionService; + let flowHelper: FlowHelper; + let authStateService: AuthStateService; + let silentRenewService: SilentRenewService; + let storagePersistenceService: StoragePersistenceService; + let flowsDataService: FlowsDataService; + let refreshSessionIframeService: RefreshSessionIframeService; + let refreshSessionRefreshTokenService: RefreshSessionRefreshTokenService; + let authWellKnownService: AuthWellKnownService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + FlowHelper, + mockProvider(FlowsDataService), + RefreshSessionService, + mockProvider(LoggerService), + mockProvider(SilentRenewService), + mockProvider(AuthStateService), + mockProvider(AuthWellKnownService), + mockProvider(RefreshSessionIframeService), + mockProvider(StoragePersistenceService), + mockProvider(RefreshSessionRefreshTokenService), + mockProvider(UserService), + mockProvider(PublicEventsService), + ], + }); + }); + + beforeEach(() => { + refreshSessionService = TestBed.inject(RefreshSessionService); + flowsDataService = TestBed.inject(FlowsDataService); + flowHelper = TestBed.inject(FlowHelper); + authStateService = TestBed.inject(AuthStateService); + refreshSessionIframeService = TestBed.inject(RefreshSessionIframeService); + refreshSessionRefreshTokenService = TestBed.inject( + RefreshSessionRefreshTokenService + ); + silentRenewService = TestBed.inject(SilentRenewService); + authWellKnownService = TestBed.inject(AuthWellKnownService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + }); + + it('should create', () => { + expect(refreshSessionService).toBeTruthy(); + }); + + describe('userForceRefreshSession', () => { + it('should persist params refresh when extra custom params given and useRefreshToken is true', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const writeSpy = spyOn(storagePersistenceService, 'write'); + const allConfigs = [ + { + configId: 'configId1', + useRefreshToken: true, + silentRenewTimeoutInSeconds: 10, + }, + ]; + + const extraCustomParams = { extra: 'custom' }; + + refreshSessionService + .userForceRefreshSession(allConfigs[0], allConfigs, extraCustomParams) + .subscribe(() => { + expect(writeSpy).toHaveBeenCalledOnceWith( + 'storageCustomParamsRefresh', + extraCustomParams, + allConfigs[0] + ); + }); + })); + + it('should persist storageCustomParamsAuthRequest when extra custom params given and useRefreshToken is false', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const allConfigs = [ + { + configId: 'configId1', + useRefreshToken: false, + silentRenewTimeoutInSeconds: 10, + }, + ]; + const writeSpy = spyOn(storagePersistenceService, 'write'); + + const extraCustomParams = { extra: 'custom' }; + + refreshSessionService + .userForceRefreshSession(allConfigs[0], allConfigs, extraCustomParams) + .subscribe(() => { + expect(writeSpy).toHaveBeenCalledOnceWith( + 'storageCustomParamsAuthRequest', + extraCustomParams, + allConfigs[0] + ); + }); + })); + + it('should NOT persist customparams if no customparams are given', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + const allConfigs = [ + { + configId: 'configId1', + useRefreshToken: false, + silentRenewTimeoutInSeconds: 10, + }, + ]; + const writeSpy = spyOn(storagePersistenceService, 'write'); + + refreshSessionService + .userForceRefreshSession(allConfigs[0], allConfigs) + .subscribe(() => { + expect(writeSpy).not.toHaveBeenCalled(); + }); + })); + + it('should call resetSilentRenewRunning in case of an error', waitForAsync(() => { + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + throwError(() => new Error('error')) + ); + spyOn(flowsDataService, 'resetSilentRenewRunning'); + const allConfigs = [ + { + configId: 'configId1', + useRefreshToken: false, + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .userForceRefreshSession(allConfigs[0], allConfigs) + .subscribe({ + next: () => { + fail('It should not return any result.'); + }, + error: (error) => { + expect(error).toBeInstanceOf(Error); + }, + complete: () => { + expect( + flowsDataService.resetSilentRenewRunning + ).toHaveBeenCalledOnceWith(allConfigs[0]); + }, + }); + })); + + it('should call resetSilentRenewRunning in case of no error', waitForAsync(() => { + spyOn(refreshSessionService, 'forceRefreshSession').and.returnValue( + of({} as LoginResponse) + ); + spyOn(flowsDataService, 'resetSilentRenewRunning'); + const allConfigs = [ + { + configId: 'configId1', + useRefreshToken: false, + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .userForceRefreshSession(allConfigs[0], allConfigs) + .subscribe({ + error: () => { + fail('It should not return any error.'); + }, + complete: () => { + expect( + flowsDataService.resetSilentRenewRunning + ).toHaveBeenCalledOnceWith(allConfigs[0]); + }, + }); + })); + }); + + describe('forceRefreshSession', () => { + it('only calls start refresh session and returns idToken and accessToken if auth is true', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOn(authStateService, 'getIdToken').and.returnValue('id-token'); + spyOn(authStateService, 'getAccessToken').and.returnValue('access-token'); + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result.idToken).toEqual('id-token'); + expect(result.accessToken).toEqual('access-token'); + }); + })); + + it('only calls start refresh session and returns null if auth is false', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId: 'configId1', + }); + }); + })); + + it('calls start refresh session and waits for completed, returns idtoken and accesstoken if auth is true', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + true + ); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue( + of({ + authResult: { + id_token: 'some-id_token', + access_token: 'some-access_token', + }, + } as CallbackContext) + ); + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result.idToken).toBeDefined(); + expect(result.accessToken).toBeDefined(); + }); + })); + + it('calls start refresh session and waits for completed, returns LoginResponse if auth is false', waitForAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue(of(null)); + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId: 'configId1', + }); + }); + })); + + it('occurs timeout error and retry mechanism exhausted max retry count throws error', fakeAsync(() => { + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue(of(null).pipe(delay(11000))); + + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const expectedInvokeCount = MAX_RETRY_ATTEMPTS; + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe({ + next: () => { + fail('It should not return any result.'); + }, + error: (error) => { + expect(error).toBeInstanceOf(Error); + expect(resetSilentRenewRunningSpy).toHaveBeenCalledTimes( + expectedInvokeCount + ); + }, + }); + + tick(allConfigs[0].silentRenewTimeoutInSeconds * 10000); + })); + + it('occurs unknown error throws it to subscriber', fakeAsync(() => { + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + const expectedErrorMessage = 'Test error message'; + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue(of(null)); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(throwError(() => new Error(expectedErrorMessage))); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe({ + next: () => { + fail('It should not return any result.'); + }, + error: (error) => { + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual(`Error: ${expectedErrorMessage}`); + expect(resetSilentRenewRunningSpy).not.toHaveBeenCalled(); + }, + }); + })); + + describe('NOT isCurrentFlowCodeFlowWithRefreshTokens', () => { + it('does return null when not authenticated', waitForAsync(() => { + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOn(authStateService, 'areAuthStorageTokensValid').and.returnValue( + false + ); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue(of(null)); + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId: 'configId1', + }); + }); + })); + + it('return value only returns once', waitForAsync(() => { + const allConfigs = [ + { + configId: 'configId1', + silentRenewTimeoutInSeconds: 10, + }, + ]; + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + spyOn( + refreshSessionService as any, + 'startRefreshSession' + ).and.returnValue(of(null)); + spyOnProperty( + silentRenewService, + 'refreshSessionWithIFrameCompleted$' + ).and.returnValue( + of({ + authResult: { + id_token: 'some-id_token', + access_token: 'some-access_token', + }, + } as CallbackContext) + ); + const spyInsideMap = spyOn( + authStateService, + 'areAuthStorageTokensValid' + ).and.returnValue(true); + + refreshSessionService + .forceRefreshSession(allConfigs[0], allConfigs) + .subscribe((result) => { + expect(result).toEqual({ + idToken: 'some-id_token', + accessToken: 'some-access_token', + isAuthenticated: true, + userData: undefined, + configId: 'configId1', + }); + expect(spyInsideMap).toHaveBeenCalledTimes(1); + }); + })); + }); + }); + + describe('startRefreshSession', () => { + it('returns null if no auth well known endpoint defined', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + + (refreshSessionService as any) + .startRefreshSession() + .subscribe((result: any) => { + expect(result).toBe(null); + }); + })); + + it('returns null if silent renew Is running', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + + (refreshSessionService as any) + .startRefreshSession() + .subscribe((result: any) => { + expect(result).toBe(null); + }); + })); + + it('calls `setSilentRenewRunning` when should be executed', waitForAsync(() => { + const setSilentRenewRunningSpy = spyOn( + flowsDataService, + 'setSilentRenewRunning' + ); + const allConfigs = [ + { + configId: 'configId1', + authWellknownEndpointUrl: 'https://authWell', + }, + ]; + + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + + (refreshSessionService as any) + .startRefreshSession(allConfigs[0], allConfigs) + .subscribe(() => { + expect(setSilentRenewRunningSpy).toHaveBeenCalled(); + }); + })); + + it('calls refreshSessionWithRefreshTokens when current flow is codeflow with refresh tokens', waitForAsync(() => { + spyOn(flowsDataService, 'setSilentRenewRunning'); + const allConfigs = [ + { + configId: 'configId1', + authWellknownEndpointUrl: 'https://authWell', + }, + ]; + + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(true); + const refreshSessionWithRefreshTokensSpy = spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + + (refreshSessionService as any) + .startRefreshSession(allConfigs[0], allConfigs) + .subscribe(() => { + expect(refreshSessionWithRefreshTokensSpy).toHaveBeenCalled(); + }); + })); + + it('calls refreshSessionWithIframe when current flow is NOT codeflow with refresh tokens', waitForAsync(() => { + spyOn(flowsDataService, 'setSilentRenewRunning'); + const allConfigs = [ + { + configId: 'configId1', + authWellknownEndpointUrl: 'https://authWell', + }, + ]; + + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn( + flowHelper, + 'isCurrentFlowCodeFlowWithRefreshTokens' + ).and.returnValue(false); + const refreshSessionWithRefreshTokensSpy = spyOn( + refreshSessionRefreshTokenService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + + const refreshSessionWithIframeSpy = spyOn( + refreshSessionIframeService, + 'refreshSessionWithIframe' + ).and.returnValue(of(false)); + + (refreshSessionService as any) + .startRefreshSession(allConfigs[0], allConfigs) + .subscribe(() => { + expect(refreshSessionWithRefreshTokensSpy).not.toHaveBeenCalled(); + expect(refreshSessionWithIframeSpy).toHaveBeenCalled(); + }); + })); + }); +}); diff --git a/src/callback/refresh-session.service.ts b/src/callback/refresh-session.service.ts new file mode 100644 index 0000000..bea2425 --- /dev/null +++ b/src/callback/refresh-session.service.ts @@ -0,0 +1,245 @@ +import { inject, Injectable } from 'injection-js'; +import { + forkJoin, + Observable, + of, + throwError, + TimeoutError, + timer, +} from 'rxjs'; +import { + map, + mergeMap, + retryWhen, + switchMap, + take, + tap, + timeout, +} from 'rxjs/operators'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { AuthWellKnownService } from '../config/auth-well-known/auth-well-known.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { RefreshSessionIframeService } from '../iframe/refresh-session-iframe.service'; +import { SilentRenewService } from '../iframe/silent-renew.service'; +import { LoggerService } from '../logging/logger.service'; +import { LoginResponse } from '../login/login-response'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { UserService } from '../user-data/user.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { RefreshSessionRefreshTokenService } from './refresh-session-refresh-token.service'; + +export const MAX_RETRY_ATTEMPTS = 3; + +@Injectable() +export class RefreshSessionService { + private readonly flowHelper = inject(FlowHelper); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly loggerService = inject(LoggerService); + + private readonly silentRenewService = inject(SilentRenewService); + + private readonly authStateService = inject(AuthStateService); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + private readonly refreshSessionIframeService = inject( + RefreshSessionIframeService + ); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly refreshSessionRefreshTokenService = inject( + RefreshSessionRefreshTokenService + ); + + private readonly userService = inject(UserService); + + userForceRefreshSession( + config: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[], + extraCustomParams?: { [key: string]: string | number | boolean } + ): Observable { + if (!config) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + this.persistCustomParams(extraCustomParams, config); + + return this.forceRefreshSession(config, allConfigs, extraCustomParams).pipe( + tap(() => this.flowsDataService.resetSilentRenewRunning(config)) + ); + } + + forceRefreshSession( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + extraCustomParams?: { [key: string]: string | number | boolean } + ): Observable { + const { customParamsRefreshTokenRequest, configId } = config; + const mergedParams = { + ...customParamsRefreshTokenRequest, + ...extraCustomParams, + }; + + if (this.flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config)) { + return this.startRefreshSession(config, allConfigs, mergedParams).pipe( + map(() => { + const isAuthenticated = + this.authStateService.areAuthStorageTokensValid(config); + + if (isAuthenticated) { + return { + idToken: this.authStateService.getIdToken(config), + accessToken: this.authStateService.getAccessToken(config), + userData: this.userService.getUserDataFromStore(config), + isAuthenticated, + configId, + } as LoginResponse; + } + + return { + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId, + }; + }) + ); + } + + const { silentRenewTimeoutInSeconds } = config; + const timeOutTime = (silentRenewTimeoutInSeconds ?? 0) * 1000; + + return forkJoin([ + this.startRefreshSession(config, allConfigs, extraCustomParams), + this.silentRenewService.refreshSessionWithIFrameCompleted$.pipe(take(1)), + ]).pipe( + timeout(timeOutTime), + retryWhen((errors) => { + return errors.pipe( + mergeMap((error, index) => { + const scalingDuration = 1000; + const currentAttempt = index + 1; + + if ( + !(error instanceof TimeoutError) || + currentAttempt > MAX_RETRY_ATTEMPTS + ) { + return throwError(() => new Error(error)); + } + + this.loggerService.logDebug( + config, + `forceRefreshSession timeout. Attempt #${currentAttempt}` + ); + + this.flowsDataService.resetSilentRenewRunning(config); + + return timer(currentAttempt * scalingDuration); + }) + ); + }), + map(([_, callbackContext]) => { + const isAuthenticated = + this.authStateService.areAuthStorageTokensValid(config); + + if (isAuthenticated) { + return { + idToken: callbackContext?.authResult?.id_token ?? '', + accessToken: callbackContext?.authResult?.access_token ?? '', + userData: this.userService.getUserDataFromStore(config), + isAuthenticated, + configId, + }; + } + + return { + isAuthenticated: false, + errorMessage: '', + userData: null, + idToken: '', + accessToken: '', + configId, + }; + }) + ); + } + + private persistCustomParams( + extraCustomParams: { [key: string]: string | number | boolean } | undefined, + config: OpenIdConfiguration + ): void { + const { useRefreshToken } = config; + + if (extraCustomParams) { + if (useRefreshToken) { + this.storagePersistenceService.write( + 'storageCustomParamsRefresh', + extraCustomParams, + config + ); + } else { + this.storagePersistenceService.write( + 'storageCustomParamsAuthRequest', + extraCustomParams, + config + ); + } + } + } + + private startRefreshSession( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + extraCustomParams?: { [key: string]: string | number | boolean } + ): Observable { + const isSilentRenewRunning = + this.flowsDataService.isSilentRenewRunning(config); + + this.loggerService.logDebug( + config, + `Checking: silentRenewRunning: ${isSilentRenewRunning}` + ); + const shouldBeExecuted = !isSilentRenewRunning; + + if (!shouldBeExecuted) { + return of(null); + } + + return this.authWellKnownService + .queryAndStoreAuthWellKnownEndPoints(config) + .pipe( + switchMap(() => { + this.flowsDataService.setSilentRenewRunning(config); + + if (this.flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config)) { + // Refresh Session using Refresh tokens + return this.refreshSessionRefreshTokenService.refreshSessionWithRefreshTokens( + config, + allConfigs, + extraCustomParams + ); + } + + return this.refreshSessionIframeService.refreshSessionWithIframe( + config, + allConfigs, + extraCustomParams + ); + }) + ); + } +} diff --git a/src/config/auth-well-known/auth-well-known-data.service.spec.ts b/src/config/auth-well-known/auth-well-known-data.service.spec.ts new file mode 100644 index 0000000..383f906 --- /dev/null +++ b/src/config/auth-well-known/auth-well-known-data.service.spec.ts @@ -0,0 +1,237 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { createRetriableStream } from '../../../test/create-retriable-stream.helper'; +import { DataService } from '../../api/data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { AuthWellKnownDataService } from './auth-well-known-data.service'; +import { AuthWellKnownEndpoints } from './auth-well-known-endpoints'; + +const DUMMY_WELL_KNOWN_DOCUMENT = { + issuer: 'https://identity-server.test/realms/main', + authorization_endpoint: + 'https://identity-server.test/realms/main/protocol/openid-connect/auth', + token_endpoint: + 'https://identity-server.test/realms/main/protocol/openid-connect/token', + userinfo_endpoint: + 'https://identity-server.test/realms/main/protocol/openid-connect/userinfo', + end_session_endpoint: + 'https://identity-server.test/realms/main/master/protocol/openid-connect/logout', + jwks_uri: + 'https://identity-server.test/realms/main/protocol/openid-connect/certs', + check_session_iframe: + 'https://identity-server.test/realms/main/protocol/openid-connect/login-status-iframe.html', + introspection_endpoint: + 'https://identity-server.test/realms/main/protocol/openid-connect/token/introspect', +}; + +describe('AuthWellKnownDataService', () => { + let service: AuthWellKnownDataService; + let dataService: DataService; + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AuthWellKnownDataService, + mockProvider(DataService), + mockProvider(LoggerService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(AuthWellKnownDataService); + loggerService = TestBed.inject(LoggerService); + dataService = TestBed.inject(DataService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getWellKnownDocument', () => { + it('should add suffix if it does not exist on current URL', waitForAsync(() => { + const dataServiceSpy = spyOn(dataService, 'get').and.returnValue( + of(null) + ); + const urlWithoutSuffix = 'myUrl'; + const urlWithSuffix = `${urlWithoutSuffix}/.well-known/openid-configuration`; + + (service as any) + .getWellKnownDocument(urlWithoutSuffix, { configId: 'configId1' }) + .subscribe(() => { + expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, { + configId: 'configId1', + }); + }); + })); + + it('should not add suffix if it does exist on current url', waitForAsync(() => { + const dataServiceSpy = spyOn(dataService, 'get').and.returnValue( + of(null) + ); + const urlWithSuffix = `myUrl/.well-known/openid-configuration`; + + (service as any) + .getWellKnownDocument(urlWithSuffix, { configId: 'configId1' }) + .subscribe(() => { + expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, { + configId: 'configId1', + }); + }); + })); + + it('should not add suffix if it does exist in the middle of current url', waitForAsync(() => { + const dataServiceSpy = spyOn(dataService, 'get').and.returnValue( + of(null) + ); + const urlWithSuffix = `myUrl/.well-known/openid-configuration/and/some/more/stuff`; + + (service as any) + .getWellKnownDocument(urlWithSuffix, { configId: 'configId1' }) + .subscribe(() => { + expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, { + configId: 'configId1', + }); + }); + })); + + it('should use the custom suffix provided in the config', waitForAsync(() => { + const dataServiceSpy = spyOn(dataService, 'get').and.returnValue( + of(null) + ); + const urlWithoutSuffix = `myUrl`; + const urlWithSuffix = `${urlWithoutSuffix}/.well-known/test-openid-configuration`; + + (service as any) + .getWellKnownDocument(urlWithoutSuffix, { + configId: 'configId1', + authWellknownUrlSuffix: '/.well-known/test-openid-configuration', + }) + .subscribe(() => { + expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, { + configId: 'configId1', + authWellknownUrlSuffix: '/.well-known/test-openid-configuration', + }); + }); + })); + + it('should retry once', waitForAsync(() => { + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('one')), + of(DUMMY_WELL_KNOWN_DOCUMENT) + ) + ); + + (service as any) + .getWellKnownDocument('anyurl', { configId: 'configId1' }) + .subscribe({ + next: (res: unknown) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_WELL_KNOWN_DOCUMENT); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('one')), + throwError(() => new Error('two')), + of(DUMMY_WELL_KNOWN_DOCUMENT) + ) + ); + + (service as any) + .getWellKnownDocument('anyurl', { configId: 'configId1' }) + .subscribe({ + next: (res: any) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_WELL_KNOWN_DOCUMENT); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('one')), + throwError(() => new Error('two')), + throwError(() => new Error('three')), + of(DUMMY_WELL_KNOWN_DOCUMENT) + ) + ); + + (service as any).getWellKnownDocument('anyurl', 'configId').subscribe({ + error: (err: unknown) => { + expect(err).toBeTruthy(); + }, + }); + })); + }); + + describe('getWellKnownEndPointsForConfig', () => { + it('calling internal getWellKnownDocument and maps', waitForAsync(() => { + spyOn(dataService, 'get').and.returnValue(of({ jwks_uri: 'jwks_uri' })); + + const spy = spyOn( + service as any, + 'getWellKnownDocument' + ).and.callThrough(); + + service + .getWellKnownEndPointsForConfig({ + configId: 'configId1', + authWellknownEndpointUrl: 'any-url', + }) + .subscribe((result) => { + expect(spy).toHaveBeenCalled(); + expect((result as any).jwks_uri).toBeUndefined(); + expect(result.jwksUri).toBe('jwks_uri'); + }); + })); + + it('throws error and logs if no authwellknownUrl is given', waitForAsync(() => { + const loggerSpy = spyOn(loggerService, 'logError'); + const config = { + configId: 'configId1', + authWellknownEndpointUrl: undefined, + }; + + service.getWellKnownEndPointsForConfig(config).subscribe({ + error: (error) => { + expect(loggerSpy).toHaveBeenCalledOnceWith( + config, + 'no authWellknownEndpoint given!' + ); + expect(error.message).toEqual('no authWellknownEndpoint given!'); + }, + }); + })); + + it('should merge the mapped endpoints with the provided endpoints', waitForAsync(() => { + spyOn(dataService, 'get').and.returnValue(of(DUMMY_WELL_KNOWN_DOCUMENT)); + + const expected: AuthWellKnownEndpoints = { + endSessionEndpoint: 'config-endSessionEndpoint', + revocationEndpoint: 'config-revocationEndpoint', + jwksUri: DUMMY_WELL_KNOWN_DOCUMENT.jwks_uri, + }; + + service + .getWellKnownEndPointsForConfig({ + configId: 'configId1', + authWellknownEndpointUrl: 'any-url', + authWellknownEndpoints: { + endSessionEndpoint: 'config-endSessionEndpoint', + revocationEndpoint: 'config-revocationEndpoint', + }, + }) + .subscribe((result) => { + expect(result).toEqual(jasmine.objectContaining(expected)); + }); + })); + }); +}); diff --git a/src/config/auth-well-known/auth-well-known-data.service.ts b/src/config/auth-well-known/auth-well-known-data.service.ts new file mode 100644 index 0000000..426e7c1 --- /dev/null +++ b/src/config/auth-well-known/auth-well-known-data.service.ts @@ -0,0 +1,68 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, throwError } from 'rxjs'; +import { map, retry } from 'rxjs/operators'; +import { DataService } from '../../api/data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { OpenIdConfiguration } from '../openid-configuration'; +import { AuthWellKnownEndpoints } from './auth-well-known-endpoints'; + +const WELL_KNOWN_SUFFIX = `/.well-known/openid-configuration`; + +@Injectable() +export class AuthWellKnownDataService { + private readonly loggerService = inject(LoggerService); + + private readonly http = inject(DataService); + + getWellKnownEndPointsForConfig( + config: OpenIdConfiguration + ): Observable { + const { authWellknownEndpointUrl, authWellknownEndpoints = {} } = config; + + if (!authWellknownEndpointUrl) { + const errorMessage = 'no authWellknownEndpoint given!'; + + this.loggerService.logError(config, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + + return this.getWellKnownDocument(authWellknownEndpointUrl, config).pipe( + map( + (wellKnownEndpoints) => + ({ + issuer: wellKnownEndpoints.issuer, + jwksUri: wellKnownEndpoints.jwks_uri, + authorizationEndpoint: wellKnownEndpoints.authorization_endpoint, + tokenEndpoint: wellKnownEndpoints.token_endpoint, + userInfoEndpoint: wellKnownEndpoints.userinfo_endpoint, + endSessionEndpoint: wellKnownEndpoints.end_session_endpoint, + checkSessionIframe: wellKnownEndpoints.check_session_iframe, + revocationEndpoint: wellKnownEndpoints.revocation_endpoint, + introspectionEndpoint: wellKnownEndpoints.introspection_endpoint, + parEndpoint: + wellKnownEndpoints.pushed_authorization_request_endpoint, + } as AuthWellKnownEndpoints) + ), + map((mappedWellKnownEndpoints) => ({ + ...mappedWellKnownEndpoints, + ...authWellknownEndpoints, + })) + ); + } + + private getWellKnownDocument( + wellKnownEndpoint: string, + config: OpenIdConfiguration + ): Observable { + let url = wellKnownEndpoint; + + const wellKnownSuffix = config.authWellknownUrlSuffix || WELL_KNOWN_SUFFIX; + + if (!wellKnownEndpoint.includes(wellKnownSuffix)) { + url = `${wellKnownEndpoint}${wellKnownSuffix}`; + } + + return this.http.get(url, config).pipe(retry(2)); + } +} diff --git a/src/config/auth-well-known/auth-well-known-endpoints.ts b/src/config/auth-well-known/auth-well-known-endpoints.ts new file mode 100644 index 0000000..e30862c --- /dev/null +++ b/src/config/auth-well-known/auth-well-known-endpoints.ts @@ -0,0 +1,12 @@ +export interface AuthWellKnownEndpoints { + issuer?: string; + jwksUri?: string; + authorizationEndpoint?: string; + tokenEndpoint?: string; + userInfoEndpoint?: string; + endSessionEndpoint?: string; + checkSessionIframe?: string; + revocationEndpoint?: string; + introspectionEndpoint?: string; + parEndpoint?: string; +} diff --git a/src/config/auth-well-known/auth-well-known.service.spec.ts b/src/config/auth-well-known/auth-well-known.service.spec.ts new file mode 100644 index 0000000..099d93e --- /dev/null +++ b/src/config/auth-well-known/auth-well-known.service.spec.ts @@ -0,0 +1,110 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { EventTypes } from '../../public-events/event-types'; +import { PublicEventsService } from '../../public-events/public-events.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { AuthWellKnownDataService } from './auth-well-known-data.service'; +import { AuthWellKnownService } from './auth-well-known.service'; + +describe('AuthWellKnownService', () => { + let service: AuthWellKnownService; + let dataService: AuthWellKnownDataService; + let storagePersistenceService: StoragePersistenceService; + let publicEventsService: PublicEventsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AuthWellKnownService, + PublicEventsService, + mockProvider(AuthWellKnownDataService), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(AuthWellKnownService); + dataService = TestBed.inject(AuthWellKnownDataService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + publicEventsService = TestBed.inject(PublicEventsService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getAuthWellKnownEndPoints', () => { + it('getAuthWellKnownEndPoints throws an error if not config provided', waitForAsync(() => { + service.queryAndStoreAuthWellKnownEndPoints(null).subscribe({ + error: (error) => { + expect(error).toEqual( + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + }, + }); + })); + + it('getAuthWellKnownEndPoints calls always dataservice', waitForAsync(() => { + const dataServiceSpy = spyOn( + dataService, + 'getWellKnownEndPointsForConfig' + ).and.returnValue(of({ issuer: 'anything' })); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ issuer: 'anything' }); + + service + .queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' }) + .subscribe((result) => { + expect(storagePersistenceService.read).not.toHaveBeenCalled(); + expect(dataServiceSpy).toHaveBeenCalled(); + expect(result).toEqual({ issuer: 'anything' }); + }); + })); + + it('getAuthWellKnownEndPoints stored the result if http call is made', waitForAsync(() => { + const dataServiceSpy = spyOn( + dataService, + 'getWellKnownEndPointsForConfig' + ).and.returnValue(of({ issuer: 'anything' })); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue(null); + const storeSpy = spyOn(service, 'storeWellKnownEndpoints'); + + service + .queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' }) + .subscribe((result) => { + expect(dataServiceSpy).toHaveBeenCalled(); + expect(storeSpy).toHaveBeenCalled(); + expect(result).toEqual({ issuer: 'anything' }); + }); + })); + + it('throws `ConfigLoadingFailed` event when error happens from http', waitForAsync(() => { + spyOn(dataService, 'getWellKnownEndPointsForConfig').and.returnValue( + throwError(() => new Error('error')) + ); + const publicEventsServiceSpy = spyOn(publicEventsService, 'fireEvent'); + + service + .queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + expect(publicEventsServiceSpy).toHaveBeenCalledTimes(1); + expect(publicEventsServiceSpy).toHaveBeenCalledOnceWith( + EventTypes.ConfigLoadingFailed, + null + ); + }, + }); + })); + }); +}); diff --git a/src/config/auth-well-known/auth-well-known.service.ts b/src/config/auth-well-known/auth-well-known.service.ts new file mode 100644 index 0000000..d041763 --- /dev/null +++ b/src/config/auth-well-known/auth-well-known.service.ts @@ -0,0 +1,58 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { EventTypes } from '../../public-events/event-types'; +import { PublicEventsService } from '../../public-events/public-events.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { OpenIdConfiguration } from '../openid-configuration'; +import { AuthWellKnownDataService } from './auth-well-known-data.service'; +import { AuthWellKnownEndpoints } from './auth-well-known-endpoints'; + +@Injectable() +export class AuthWellKnownService { + private readonly dataService = inject(AuthWellKnownDataService); + + private readonly publicEventsService = inject(PublicEventsService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + storeWellKnownEndpoints( + config: OpenIdConfiguration, + mappedWellKnownEndpoints: AuthWellKnownEndpoints + ): void { + this.storagePersistenceService.write( + 'authWellKnownEndPoints', + mappedWellKnownEndpoints, + config + ); + } + + queryAndStoreAuthWellKnownEndPoints( + config: OpenIdConfiguration | null + ): Observable { + if (!config) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + return this.dataService.getWellKnownEndPointsForConfig(config).pipe( + tap((mappedWellKnownEndpoints) => + this.storeWellKnownEndpoints(config, mappedWellKnownEndpoints) + ), + catchError((error) => { + this.publicEventsService.fireEvent( + EventTypes.ConfigLoadingFailed, + null + ); + + return throwError(() => new Error(error)); + }) + ); + } +} diff --git a/src/config/config.service.spec.ts b/src/config/config.service.spec.ts new file mode 100644 index 0000000..394543e --- /dev/null +++ b/src/config/config.service.spec.ts @@ -0,0 +1,290 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockAbstractProvider, mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { PlatformProvider } from '../utils/platform-provider/platform.provider'; +import { AuthWellKnownService } from './auth-well-known/auth-well-known.service'; +import { ConfigurationService } from './config.service'; +import { StsConfigLoader, StsConfigStaticLoader } from './loader/config-loader'; +import { OpenIdConfiguration } from './openid-configuration'; +import { ConfigValidationService } from './validation/config-validation.service'; + +describe('Configuration Service', () => { + let configService: ConfigurationService; + let publicEventsService: PublicEventsService; + let authWellKnownService: AuthWellKnownService; + let storagePersistenceService: StoragePersistenceService; + let configValidationService: ConfigValidationService; + let platformProvider: PlatformProvider; + let stsConfigLoader: StsConfigLoader; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ConfigurationService, + mockProvider(LoggerService), + PublicEventsService, + mockProvider(StoragePersistenceService), + ConfigValidationService, + mockProvider(PlatformProvider), + mockProvider(AuthWellKnownService), + mockAbstractProvider(StsConfigLoader, StsConfigStaticLoader), + ], + }); + }); + + beforeEach(() => { + configService = TestBed.inject(ConfigurationService); + publicEventsService = TestBed.inject(PublicEventsService); + authWellKnownService = TestBed.inject(AuthWellKnownService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + stsConfigLoader = TestBed.inject(StsConfigLoader); + platformProvider = TestBed.inject(PlatformProvider); + configValidationService = TestBed.inject(ConfigValidationService); + }); + + it('should create', () => { + expect(configService).toBeTruthy(); + }); + + describe('hasManyConfigs', () => { + it('returns true if many configs are stored', () => { + (configService as any).configsInternal = { + configId1: { configId: 'configId1' }, + configId2: { configId: 'configId2' }, + }; + + const result = configService.hasManyConfigs(); + + expect(result).toBe(true); + }); + + it('returns false if only one config is stored', () => { + (configService as any).configsInternal = { + configId1: { configId: 'configId1' }, + }; + + const result = configService.hasManyConfigs(); + + expect(result).toBe(false); + }); + }); + + describe('getAllConfigurations', () => { + it('returns all configs as array', () => { + (configService as any).configsInternal = { + configId1: { configId: 'configId1' }, + configId2: { configId: 'configId2' }, + }; + + const result = configService.getAllConfigurations(); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + }); + }); + + describe('getOpenIDConfiguration', () => { + it(`if config is already saved 'loadConfigs' is not called`, waitForAsync(() => { + (configService as any).configsInternal = { + configId1: { configId: 'configId1' }, + configId2: { configId: 'configId2' }, + }; + const spy = spyOn(configService as any, 'loadConfigs'); + + configService.getOpenIDConfiguration('configId1').subscribe((config) => { + expect(config).toBeTruthy(); + expect(spy).not.toHaveBeenCalled(); + }); + })); + + it(`if config is NOT already saved 'loadConfigs' is called`, waitForAsync(() => { + const configs = [{ configId: 'configId1' }, { configId: 'configId2' }]; + const spy = spyOn(configService as any, 'loadConfigs').and.returnValue( + of(configs) + ); + + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + + configService.getOpenIDConfiguration('configId1').subscribe((config) => { + expect(config).toBeTruthy(); + expect(spy).toHaveBeenCalled(); + }); + })); + + it(`returns null if config is not valid`, waitForAsync(() => { + const configs = [{ configId: 'configId1' }]; + + spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs)); + spyOn(configValidationService, 'validateConfig').and.returnValue(false); + const consoleSpy = spyOn(console, 'warn'); + + configService.getOpenIDConfiguration('configId1').subscribe((config) => { + expect(config).toBeNull(); + expect(consoleSpy).toHaveBeenCalledOnceWith(`[angular-auth-oidc-client] No configuration found for config id 'configId1'.`) + }); + })); + + it(`returns null if configs are stored but not existing ID is passed`, waitForAsync(() => { + (configService as any).configsInternal = { + configId1: { configId: 'configId1' }, + configId2: { configId: 'configId2' }, + }; + + configService + .getOpenIDConfiguration('notExisting') + .subscribe((config) => { + expect(config).toBeNull(); + }); + })); + + it(`sets authWellKnownEndPoints on config if authWellKnownEndPoints is stored`, waitForAsync(() => { + const configs = [{ configId: 'configId1' }]; + + spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs)); + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + const consoleSpy = spyOn(console, 'warn'); + + spyOn(storagePersistenceService, 'read').and.returnValue({ + issuer: 'auth-well-known', + }); + + configService.getOpenIDConfiguration('configId1').subscribe((config) => { + expect(config?.authWellknownEndpoints).toEqual({ + issuer: 'auth-well-known', + }); + expect(consoleSpy).not.toHaveBeenCalled() + }); + })); + + it(`fires ConfigLoaded if authWellKnownEndPoints is stored`, waitForAsync(() => { + const configs = [{ configId: 'configId1' }]; + + spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs)); + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + spyOn(storagePersistenceService, 'read').and.returnValue({ + issuer: 'auth-well-known', + }); + + const spy = spyOn(publicEventsService, 'fireEvent'); + + configService.getOpenIDConfiguration('configId1').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + EventTypes.ConfigLoaded, + jasmine.anything() + ); + }); + })); + + it(`stores, uses and fires event when authwellknownendpoints are passed`, waitForAsync(() => { + const configs = [ + { + configId: 'configId1', + authWellknownEndpoints: { issuer: 'auth-well-known' }, + }, + ]; + + spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs)); + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + spyOn(storagePersistenceService, 'read').and.returnValue(null); + + const fireEventSpy = spyOn(publicEventsService, 'fireEvent'); + const storeWellKnownEndpointsSpy = spyOn( + authWellKnownService, + 'storeWellKnownEndpoints' + ); + + configService.getOpenIDConfiguration('configId1').subscribe((config) => { + expect(config).toBeTruthy(); + expect(fireEventSpy).toHaveBeenCalledOnceWith( + EventTypes.ConfigLoaded, + jasmine.anything() + ); + expect(storeWellKnownEndpointsSpy).toHaveBeenCalledOnceWith( + config as OpenIdConfiguration, + { + issuer: 'auth-well-known', + } + ); + }); + })); + }); + + describe('getOpenIDConfigurations', () => { + it(`returns correct result`, waitForAsync(() => { + spyOn(stsConfigLoader, 'loadConfigs').and.returnValue( + of([ + { configId: 'configId1' } as OpenIdConfiguration, + { configId: 'configId2' } as OpenIdConfiguration, + ]) + ); + + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + + configService.getOpenIDConfigurations('configId1').subscribe((result) => { + expect(result.allConfigs.length).toEqual(2); + expect(result.currentConfig).toBeTruthy(); + }); + })); + + it(`created configId when configId is not set`, waitForAsync(() => { + spyOn(stsConfigLoader, 'loadConfigs').and.returnValue( + of([ + { clientId: 'clientId1' } as OpenIdConfiguration, + { clientId: 'clientId2' } as OpenIdConfiguration, + ]) + ); + + spyOn(configValidationService, 'validateConfig').and.returnValue(true); + + configService.getOpenIDConfigurations().subscribe((result) => { + expect(result.allConfigs.length).toEqual(2); + const allConfigIds = result.allConfigs.map((x) => x.configId); + + expect(allConfigIds).toEqual(['0-clientId1', '1-clientId2']); + + expect(result.currentConfig).toBeTruthy(); + expect(result.currentConfig?.configId).toBeTruthy(); + }); + })); + + it(`returns empty array if config is not valid`, waitForAsync(() => { + spyOn(stsConfigLoader, 'loadConfigs').and.returnValue( + of([ + { configId: 'configId1' } as OpenIdConfiguration, + { configId: 'configId2' } as OpenIdConfiguration, + ]) + ); + + spyOn(configValidationService, 'validateConfigs').and.returnValue(false); + + configService + .getOpenIDConfigurations() + .subscribe(({ allConfigs, currentConfig }) => { + expect(allConfigs).toEqual([]); + expect(currentConfig).toBeNull(); + }); + })); + }); + + describe('setSpecialCases', () => { + it(`should set special cases when current platform is browser`, () => { + spyOn(platformProvider, 'isBrowser').and.returnValue(false); + + const config = { configId: 'configId1' } as OpenIdConfiguration; + + (configService as any).setSpecialCases(config); + + expect(config).toEqual({ + configId: 'configId1', + startCheckSession: false, + silentRenew: false, + useRefreshToken: false, + usePushedAuthorisationRequests: false, + }); + }); + }); +}); diff --git a/src/config/config.service.ts b/src/config/config.service.ts new file mode 100644 index 0000000..440ba89 --- /dev/null +++ b/src/config/config.service.ts @@ -0,0 +1,208 @@ +import {inject, Injectable, isDevMode} from 'injection-js'; +import { forkJoin, Observable, of } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { PlatformProvider } from '../utils/platform-provider/platform.provider'; +import { AuthWellKnownService } from './auth-well-known/auth-well-known.service'; +import { DEFAULT_CONFIG } from './default-config'; +import { StsConfigLoader } from './loader/config-loader'; +import { OpenIdConfiguration } from './openid-configuration'; +import { ConfigValidationService } from './validation/config-validation.service'; + +@Injectable() +export class ConfigurationService { + private readonly loggerService = inject(LoggerService); + + private readonly publicEventsService = inject(PublicEventsService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly platformProvider = inject(PlatformProvider); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + private readonly loader = inject(StsConfigLoader); + + private readonly configValidationService = inject(ConfigValidationService); + + private configsInternal: Record = {}; + + hasManyConfigs(): boolean { + return Object.keys(this.configsInternal).length > 1; + } + + getAllConfigurations(): OpenIdConfiguration[] { + return Object.values(this.configsInternal); + } + + getOpenIDConfiguration( + configId?: string + ): Observable { + if (this.configsAlreadySaved()) { + return of(this.getConfig(configId)); + } + + return this.getOpenIDConfigurations(configId).pipe( + map((result) => result.currentConfig) + ); + } + + getOpenIDConfigurations(configId?: string): Observable<{ + allConfigs: OpenIdConfiguration[]; + currentConfig: OpenIdConfiguration | null; + }> { + return this.loadConfigs().pipe( + concatMap((allConfigs) => this.prepareAndSaveConfigs(allConfigs)), + map((allPreparedConfigs) => ({ + allConfigs: allPreparedConfigs, + currentConfig: this.getConfig(configId), + })) + ); + } + + hasAtLeastOneConfig(): boolean { + return Object.keys(this.configsInternal).length > 0; + } + + private saveConfig(readyConfig: OpenIdConfiguration): void { + const { configId } = readyConfig; + + this.configsInternal[configId as string] = readyConfig; + } + + private loadConfigs(): Observable { + return this.loader.loadConfigs(); + } + + private configsAlreadySaved(): boolean { + return this.hasAtLeastOneConfig(); + } + + private getConfig(configId?: string): OpenIdConfiguration | null { + if (Boolean(configId)) { + const config = this.configsInternal[configId!]; + + if(!config && isDevMode()) { + console.warn(`[angular-auth-oidc-client] No configuration found for config id '${configId}'.`); + } + + return config || null; + } + + const [, value] = Object.entries(this.configsInternal)[0] || [[null, null]]; + + return value || null; + } + + private prepareAndSaveConfigs( + passedConfigs: OpenIdConfiguration[] + ): Observable { + if (!this.configValidationService.validateConfigs(passedConfigs)) { + return of([]); + } + + this.createUniqueIds(passedConfigs); + + const allHandleConfigs$ = passedConfigs.map((x) => this.handleConfig(x)); + const as = forkJoin(allHandleConfigs$).pipe( + map((config) => config.filter((conf) => Boolean(conf))), + map((c) => c as OpenIdConfiguration[]) + ); + + return as; + } + + private createUniqueIds(passedConfigs: OpenIdConfiguration[]): void { + passedConfigs.forEach((config, index) => { + if (!config.configId) { + config.configId = `${index}-${config.clientId}`; + } + }); + } + + private handleConfig( + passedConfig: OpenIdConfiguration + ): Observable { + if (!this.configValidationService.validateConfig(passedConfig)) { + this.loggerService.logError( + passedConfig, + 'Validation of config rejected with errors. Config is NOT set.' + ); + + return of(null); + } + + if (!passedConfig.authWellknownEndpointUrl) { + passedConfig.authWellknownEndpointUrl = passedConfig.authority; + } + + const usedConfig = this.prepareConfig(passedConfig); + + this.saveConfig(usedConfig); + + const configWithAuthWellKnown = + this.enhanceConfigWithWellKnownEndpoint(usedConfig); + + this.publicEventsService.fireEvent( + EventTypes.ConfigLoaded, + configWithAuthWellKnown + ); + + return of(usedConfig); + } + + private enhanceConfigWithWellKnownEndpoint( + configuration: OpenIdConfiguration + ): OpenIdConfiguration { + const alreadyExistingAuthWellKnownEndpoints = + this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (!!alreadyExistingAuthWellKnownEndpoints) { + configuration.authWellknownEndpoints = + alreadyExistingAuthWellKnownEndpoints; + + return configuration; + } + + const passedAuthWellKnownEndpoints = configuration.authWellknownEndpoints; + + if (!!passedAuthWellKnownEndpoints) { + this.authWellKnownService.storeWellKnownEndpoints( + configuration, + passedAuthWellKnownEndpoints + ); + configuration.authWellknownEndpoints = passedAuthWellKnownEndpoints; + + return configuration; + } + + return configuration; + } + + private prepareConfig( + configuration: OpenIdConfiguration + ): OpenIdConfiguration { + const openIdConfigurationInternal = { ...DEFAULT_CONFIG, ...configuration }; + + this.setSpecialCases(openIdConfigurationInternal); + + return openIdConfigurationInternal; + } + + private setSpecialCases(currentConfig: OpenIdConfiguration): void { + if (!this.platformProvider.isBrowser()) { + currentConfig.startCheckSession = false; + currentConfig.silentRenew = false; + currentConfig.useRefreshToken = false; + currentConfig.usePushedAuthorisationRequests = false; + } + } +} diff --git a/src/config/default-config.ts b/src/config/default-config.ts new file mode 100644 index 0000000..fd8900b --- /dev/null +++ b/src/config/default-config.ts @@ -0,0 +1,43 @@ +import { LogLevel } from '../logging/log-level'; +import { OpenIdConfiguration } from './openid-configuration'; + +export const DEFAULT_CONFIG: OpenIdConfiguration = { + authority: 'https://please_set', + authWellknownEndpointUrl: '', + authWellknownEndpoints: undefined, + redirectUrl: 'https://please_set', + checkRedirectUrlWhenCheckingIfIsCallback: true, + clientId: 'please_set', + responseType: 'code', + scope: 'openid email profile', + hdParam: '', + postLogoutRedirectUri: 'https://please_set', + startCheckSession: false, + silentRenew: false, + silentRenewUrl: 'https://please_set', + silentRenewTimeoutInSeconds: 20, + renewTimeBeforeTokenExpiresInSeconds: 0, + useRefreshToken: false, + usePushedAuthorisationRequests: false, + ignoreNonceAfterRefresh: false, + postLoginRoute: '/', + forbiddenRoute: '/forbidden', + unauthorizedRoute: '/unauthorized', + autoUserInfo: true, + autoCleanStateAfterAuthentication: true, + triggerAuthorizationResultEvent: false, + logLevel: LogLevel.Warn, + issValidationOff: false, + historyCleanupOff: false, + maxIdTokenIatOffsetAllowedInSeconds: 120, + disableIatOffsetValidation: false, + customParamsAuthRequest: {}, + customParamsRefreshTokenRequest: {}, + customParamsEndSessionRequest: {}, + customParamsCodeRequest: {}, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + tokenRefreshInSeconds: 4, + refreshTokenRetryInSeconds: 3, + ngswBypass: false, +}; diff --git a/src/config/loader/config-loader.spec.ts b/src/config/loader/config-loader.spec.ts new file mode 100644 index 0000000..3ee4514 --- /dev/null +++ b/src/config/loader/config-loader.spec.ts @@ -0,0 +1,86 @@ +import { waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { OpenIdConfiguration } from '../openid-configuration'; +import { StsConfigHttpLoader, StsConfigStaticLoader } from './config-loader'; + +describe('ConfigLoader', () => { + describe('StsConfigStaticLoader', () => { + describe('loadConfigs', () => { + it('returns an array if an array is passed', waitForAsync(() => { + const toPass = [ + { configId: 'configId1' } as OpenIdConfiguration, + { configId: 'configId2' } as OpenIdConfiguration, + ]; + + const loader = new StsConfigStaticLoader(toPass); + + const result$ = loader.loadConfigs(); + + result$.subscribe((result) => { + expect(Array.isArray(result)).toBeTrue(); + }); + })); + + it('returns an array if only one config is passed', waitForAsync(() => { + const loader = new StsConfigStaticLoader({ + configId: 'configId1', + } as OpenIdConfiguration); + + const result$ = loader.loadConfigs(); + + result$.subscribe((result) => { + expect(Array.isArray(result)).toBeTrue(); + }); + })); + }); + }); + + describe('StsConfigHttpLoader', () => { + describe('loadConfigs', () => { + it('returns an array if an array of observables is passed', waitForAsync(() => { + const toPass = [ + of({ configId: 'configId1' } as OpenIdConfiguration), + of({ configId: 'configId2' } as OpenIdConfiguration), + ]; + const loader = new StsConfigHttpLoader(toPass); + + const result$ = loader.loadConfigs(); + + result$.subscribe((result) => { + expect(Array.isArray(result)).toBeTrue(); + expect(result[0].configId).toBe('configId1'); + expect(result[1].configId).toBe('configId2'); + }); + })); + + it('returns an array if an observable with a config array is passed', waitForAsync(() => { + const toPass = of([ + { configId: 'configId1' } as OpenIdConfiguration, + { configId: 'configId2' } as OpenIdConfiguration, + ]); + const loader = new StsConfigHttpLoader(toPass); + + const result$ = loader.loadConfigs(); + + result$.subscribe((result) => { + expect(Array.isArray(result)).toBeTrue(); + expect(result[0].configId).toBe('configId1'); + expect(result[1].configId).toBe('configId2'); + }); + })); + + it('returns an array if only one config is passed', waitForAsync(() => { + const loader = new StsConfigHttpLoader( + of({ configId: 'configId1' } as OpenIdConfiguration) + ); + + const result$ = loader.loadConfigs(); + + result$.subscribe((result) => { + expect(Array.isArray(result)).toBeTrue(); + expect(result[0].configId).toBe('configId1'); + }); + })); + }); + }); +}); diff --git a/src/config/loader/config-loader.ts b/src/config/loader/config-loader.ts new file mode 100644 index 0000000..0dba6b9 --- /dev/null +++ b/src/config/loader/config-loader.ts @@ -0,0 +1,53 @@ +import { Provider } from 'injection-js'; +import { forkJoin, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../openid-configuration'; + +export class OpenIdConfigLoader { + loader?: Provider; +} + +export abstract class StsConfigLoader { + abstract loadConfigs(): Observable; +} + +export class StsConfigStaticLoader implements StsConfigLoader { + constructor( + private readonly passedConfigs: OpenIdConfiguration | OpenIdConfiguration[] + ) {} + + loadConfigs(): Observable { + if (Array.isArray(this.passedConfigs)) { + return of(this.passedConfigs); + } + + return of([this.passedConfigs]); + } +} + +export class StsConfigHttpLoader implements StsConfigLoader { + constructor( + private readonly configs$: + | Observable + | Observable[] + | Observable + ) {} + + loadConfigs(): Observable { + if (Array.isArray(this.configs$)) { + return forkJoin(this.configs$); + } + + const singleConfigOrArray = this.configs$ as Observable; + + return singleConfigOrArray.pipe( + map((value: unknown) => { + if (Array.isArray(value)) { + return value as OpenIdConfiguration[]; + } + + return [value] as OpenIdConfiguration[]; + }) + ); + } +} diff --git a/src/config/openid-configuration.ts b/src/config/openid-configuration.ts new file mode 100644 index 0000000..3dd95ab --- /dev/null +++ b/src/config/openid-configuration.ts @@ -0,0 +1,211 @@ +import { LogLevel } from '../logging/log-level'; +import { AuthWellKnownEndpoints } from './auth-well-known/auth-well-known-endpoints'; + +export interface OpenIdConfiguration { + /** + * To identify a configuration the `configId` parameter was introduced. + * If you do not explicitly set this value, the library will generate + * and assign the value for you. If set, the configured value is used. + * The value is optional. + */ + configId?: string; + /** + * The url to the Security Token Service (STS). The authority issues tokens. + * This field is required. + */ + authority?: string; + /** Override the default Security Token Service wellknown endpoint postfix. */ + authWellknownEndpointUrl?: string; + authWellknownEndpoints?: AuthWellKnownEndpoints; + + /** + * Override the default Security Token Service wellknown endpoint postfix. + * + * @default /.well-known/openid-configuration + */ + authWellknownUrlSuffix?: string; + + /** The redirect URL defined on the Security Token Service. */ + redirectUrl?: string; + /** + * Whether to check if current URL matches the redirect URI when determining + * if current URL is in fact the redirect URI. + * Default: true + */ + checkRedirectUrlWhenCheckingIfIsCallback?: boolean; + /** + * The Client MUST validate that the aud (audience) Claim contains its `client_id` value + * registered at the Issuer identified by the iss (issuer) Claim as an audience. + * The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, + * or if it contains additional audiences not trusted by the Client. + */ + clientId?: string; + /** + * `code`, `id_token token` or `id_token`. + * Name of the flow which can be configured. + * You must use the `id_token token` flow, if you want to access an API + * or get user data from the server. The `access_token` is required for this, + * and only returned with this flow. + */ + responseType?: string; + /** + * List of scopes which are requested from the server from this client. + * This must match the Security Token Service configuration for the client you use. + * The `openid` scope is required. The `offline_access` scope can be requested when using refresh tokens + * but this is optional and some Security Token Service do not support this or recommend not requesting this even when using + * refresh tokens in the browser. + */ + scope?: string; + /** + * Optional hd parameter for Google Auth with particular G Suite domain, + * see https://developers.google.com/identity/protocols/OpenIDConnect#hd-param + */ + hdParam?: string; + /** URL to redirect to after a server logout if using the end session API. */ + postLogoutRedirectUri?: string; + /** Starts the OpenID session management for this client. */ + startCheckSession?: boolean; + /** Renews the client tokens, once the id_token expires. Can use iframes or refresh tokens. */ + silentRenew?: boolean; + /** An optional URL to handle silent renew callbacks */ + silentRenewUrl?: string; + /** + * Sets the maximum waiting time for silent renew process. If this time is exceeded, the silent renew state will + * be reset. Default = 20. + * */ + silentRenewTimeoutInSeconds?: number; + /** + * Makes it possible to add an offset to the silent renew check in seconds. + * By entering a value, you can renew the tokens before the tokens expire. + */ + renewTimeBeforeTokenExpiresInSeconds?: number; + /** + * Allows for a custom domain to be used with Auth0. + * With this flag set the 'authority' does not have to end with + * 'auth0.com' to trigger the auth0 special handling of logouts. + */ + useCustomAuth0Domain?: boolean; + /** + * When set to true, refresh tokens are used to renew the user session. + * When set to false, standard silent renew is used. + * Default value is false. + */ + useRefreshToken?: boolean; + /** + * Activates Pushed Authorisation Requests for login and popup login. + * Not compatible with iframe renew. + */ + usePushedAuthorisationRequests?: boolean; + /** + * A token obtained by using a refresh token normally doesn't contain a nonce value. + * The library checks it is not there. However, some OIDC endpoint implementations do send one. + * Setting `ignoreNonceAfterRefresh` to `true` disables the check if a nonce is present. + * Please note that the nonce value, if present, will not be verified. Default is `false`. + */ + ignoreNonceAfterRefresh?: boolean; + /** + * The default Angular route which is used after a successful login, if not using the + * `triggerAuthorizationResultEvent` + */ + postLoginRoute?: string; + /** Route to redirect to if the server returns a 403 error. This has to be an Angular route. HTTP 403. */ + forbiddenRoute?: string; + /** Route to redirect to if the server returns a 401 error. This has to be an Angular route. HTTP 401. */ + unauthorizedRoute?: string; + /** When set to true, the library automatically gets user info after authentication */ + autoUserInfo?: boolean; + /** When set to true, the library automatically gets user info after token renew */ + renewUserInfoAfterTokenRenew?: boolean; + /** Used for custom state logic handling. The state is not automatically reset when set to false */ + autoCleanStateAfterAuthentication?: boolean; + /** + * This can be set to true which emits an event instead of an Angular route change. + * Instead of forcing the application consuming this library to automatically redirect to one of the 3 + * hard-configured routes (start, unauthorized, forbidden), this modification will add an extra + * configuration option to override such behavior and trigger an event that will allow to subscribe to + * it and let the application perform other actions. This would be useful to allow the application to + * save an initial return URL so that the user is redirected to it after a successful login on the Security Token Service + * (i.e. saving the return URL previously on sessionStorage and then retrieving it during the triggering of the event). + */ + triggerAuthorizationResultEvent?: boolean; + /** 0, 1, 2 can be used to set the log level displayed in the console. */ + logLevel?: LogLevel; + /** Make it possible to turn off the iss validation per configuration. **You should not turn this off!** */ + issValidationOff?: boolean; + /** + * If this is active, the history is not cleaned up on an authorize callback. + * This can be used when the application needs to preserve the history. + */ + historyCleanupOff?: boolean; + /** + * Amount of offset allowed between the server creating the token and the client app receiving the id_token. + * The diff in time between the server time and client time is also important in validating this value. + * All times are in UTC. + */ + maxIdTokenIatOffsetAllowedInSeconds?: number; + /** + * This allows the application to disable the iat offset validation check. + * The iat Claim can be used to reject tokens that were issued too far away from the current time, + * limiting the amount of time that nonces need to be stored to prevent attacks. + * The acceptable range is client specific. + */ + disableIatOffsetValidation?: boolean; + + /** Extra parameters to add to the authorization URL request */ + customParamsAuthRequest?: { [key: string]: string | number | boolean }; + + /** Extra parameters to add to the refresh token request body */ + customParamsRefreshTokenRequest?: { + [key: string]: string | number | boolean; + }; + + /** Extra parameters to add to the authorization EndSession request */ + customParamsEndSessionRequest?: { [key: string]: string | number | boolean }; + + /** Extra parameters to add to the token URL request */ + customParamsCodeRequest?: { [key: string]: string | number | boolean }; + + // Azure B2C have implemented this incorrectly. Add support for to disable this until fixed. + /** Disables the auth_time validation for id_tokens in a refresh due to Azure's incorrect implementation. */ + disableRefreshIdTokenAuthTimeValidation?: boolean; + + /** + * Enables the id_token validation, default value is `true`. + * You can disable this validation if you like to ignore the expired value in the renew process or not check this in the expiry check. Only the access token is used to trigger a renew. + * If no id_token is returned in using refresh tokens, set this to `false`. + */ + triggerRefreshWhenIdTokenExpired?: boolean; + + /** Controls the periodic check time interval in sections. + * Default value is 3. + */ + tokenRefreshInSeconds?: number; + /** + * Array of secure URLs on which the token should be sent if the interceptor is added to the `HTTP_INTERCEPTORS`. + */ + secureRoutes?: string[]; + /** + * Controls the periodic retry time interval for retrieving new tokens in seconds. + * `silentRenewTimeoutInSeconds` and `tokenRefreshInSeconds` are upper bounds for this value. + * Default value is 3 + */ + refreshTokenRetryInSeconds?: number; + /** Adds the ngsw-bypass param to all requests */ + ngswBypass?: boolean; + /** Allow refresh token reuse (refresh without rotation), default value is false. + * The refresh token rotation is optional (rfc6749) but is more safe and hence encouraged. + */ + allowUnsafeReuseRefreshToken?: boolean; + /** Disable validation for id_token + * This is not recommended! You should always validate the id_token if returned. + */ + disableIdTokenValidation?: boolean; + /** Disables PKCE support. + * Authorize request will be sent without code challenge. + */ + disablePkce?: boolean; + /** + * Disable cleaning up the popup when receiving invalid messages + */ + disableCleaningPopupOnInvalidMessage?: boolean +} diff --git a/src/config/validation/config-validation.service.spec.ts b/src/config/validation/config-validation.service.spec.ts new file mode 100644 index 0000000..7bf529f --- /dev/null +++ b/src/config/validation/config-validation.service.spec.ts @@ -0,0 +1,192 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { LogLevel } from '../../logging/log-level'; +import { LoggerService } from '../../logging/logger.service'; +import { OpenIdConfiguration } from '../openid-configuration'; +import { ConfigValidationService } from './config-validation.service'; +import { allMultipleConfigRules } from './rules'; + +describe('Config Validation Service', () => { + let configValidationService: ConfigValidationService; + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ConfigValidationService, mockProvider(LoggerService)], + }); + }); + + const VALID_CONFIG = { + authority: 'https://offeringsolutions-sts.azurewebsites.net', + redirectUrl: window.location.origin, + postLogoutRedirectUri: window.location.origin, + clientId: 'angularClient', + scope: 'openid profile email', + responseType: 'code', + silentRenew: true, + silentRenewUrl: `${window.location.origin}/silent-renew.html`, + renewTimeBeforeTokenExpiresInSeconds: 10, + logLevel: LogLevel.Debug, + }; + + beforeEach(() => { + configValidationService = TestBed.inject(ConfigValidationService); + loggerService = TestBed.inject(LoggerService); + }); + + it('should create', () => { + expect(configValidationService).toBeTruthy(); + }); + + it('should return false for empty config', () => { + const config = {}; + const result = configValidationService.validateConfig(config); + + expect(result).toBeFalse(); + }); + + it('should return true for valid config', () => { + const result = configValidationService.validateConfig(VALID_CONFIG); + + expect(result).toBeTrue(); + }); + + it('calls `logWarning` if one rule has warning level', () => { + const loggerWarningSpy = spyOn(loggerService, 'logWarning'); + const messageTypeSpy = spyOn( + configValidationService as any, + 'getAllMessagesOfType' + ); + + messageTypeSpy + .withArgs('warning', jasmine.any(Array)) + .and.returnValue(['A warning message']); + messageTypeSpy.withArgs('error', jasmine.any(Array)).and.callThrough(); + + configValidationService.validateConfig(VALID_CONFIG); + expect(loggerWarningSpy).toHaveBeenCalled(); + }); + + describe('ensure-clientId.rule', () => { + it('return false when no clientId is set', () => { + const config = { ...VALID_CONFIG, clientId: '' } as OpenIdConfiguration; + const result = configValidationService.validateConfig(config); + + expect(result).toBeFalse(); + }); + }); + + describe('ensure-authority-server.rule', () => { + it('return false when no security token service is set', () => { + const config = { + ...VALID_CONFIG, + authority: '', + } as OpenIdConfiguration; + const result = configValidationService.validateConfig(config); + + expect(result).toBeFalse(); + }); + }); + + describe('ensure-redirect-url.rule', () => { + it('return false for no redirect Url', () => { + const config = { ...VALID_CONFIG, redirectUrl: '' }; + const result = configValidationService.validateConfig(config); + + expect(result).toBeFalse(); + }); + }); + + describe('ensureSilentRenewUrlWhenNoRefreshTokenUsed', () => { + it('return false when silent renew is used with no useRefreshToken and no silentrenewUrl', () => { + const config = { + ...VALID_CONFIG, + silentRenew: true, + useRefreshToken: false, + silentRenewUrl: '', + } as OpenIdConfiguration; + const result = configValidationService.validateConfig(config); + + expect(result).toBeFalse(); + }); + }); + + describe('use-offline-scope-with-silent-renew.rule', () => { + it('return true but warning when silent renew is used with useRefreshToken but no offline_access scope is given', () => { + const config = { + ...VALID_CONFIG, + silentRenew: true, + useRefreshToken: true, + scopes: 'scope1 scope2 but_no_offline_access', + }; + + const loggerSpy = spyOn(loggerService, 'logError'); + const loggerWarningSpy = spyOn(loggerService, 'logWarning'); + + const result = configValidationService.validateConfig(config); + + expect(result).toBeTrue(); + expect(loggerSpy).not.toHaveBeenCalled(); + expect(loggerWarningSpy).toHaveBeenCalled(); + }); + }); + + describe('ensure-no-duplicated-configs.rule', () => { + it('should print out correct error when mutiple configs with same properties are passed', () => { + const config1 = { + ...VALID_CONFIG, + silentRenew: true, + useRefreshToken: true, + scopes: 'scope1 scope2 but_no_offline_access', + }; + const config2 = { + ...VALID_CONFIG, + silentRenew: true, + useRefreshToken: true, + scopes: 'scope1 scope2 but_no_offline_access', + }; + + const loggerErrorSpy = spyOn(loggerService, 'logError'); + const loggerWarningSpy = spyOn(loggerService, 'logWarning'); + + const result = configValidationService.validateConfigs([ + config1, + config2, + ]); + + expect(result).toBeTrue(); + expect(loggerErrorSpy).not.toHaveBeenCalled(); + expect(loggerWarningSpy.calls.argsFor(0)).toEqual([ + config1, + 'You added multiple configs with the same authority, clientId and scope', + ]); + expect(loggerWarningSpy.calls.argsFor(1)).toEqual([ + config2, + 'You added multiple configs with the same authority, clientId and scope', + ]); + }); + + it('should return false and a better error message when config is not passed as object with config property', () => { + const loggerWarningSpy = spyOn(loggerService, 'logWarning'); + + const result = configValidationService.validateConfigs([]); + + expect(result).toBeFalse(); + expect(loggerWarningSpy).not.toHaveBeenCalled(); + }); + }); + + describe('validateConfigs', () => { + it('calls internal method with empty array if something falsy is passed', () => { + const spy = spyOn( + configValidationService as any, + 'validateConfigsInternal' + ).and.callThrough(); + + const result = configValidationService.validateConfigs([]); + + expect(result).toBeFalse(); + expect(spy).toHaveBeenCalledOnceWith([], allMultipleConfigRules); + }); + }); +}); diff --git a/src/config/validation/config-validation.service.ts b/src/config/validation/config-validation.service.ts new file mode 100644 index 0000000..bd79cd6 --- /dev/null +++ b/src/config/validation/config-validation.service.ts @@ -0,0 +1,98 @@ +import { inject, Injectable } from 'injection-js'; +import { LoggerService } from '../../logging/logger.service'; +import { OpenIdConfiguration } from '../openid-configuration'; +import { Level, RuleValidationResult } from './rule'; +import { allMultipleConfigRules, allRules } from './rules'; + +@Injectable() +export class ConfigValidationService { + private readonly loggerService = inject(LoggerService); + + validateConfigs(passedConfigs: OpenIdConfiguration[]): boolean { + return this.validateConfigsInternal( + passedConfigs ?? [], + allMultipleConfigRules + ); + } + + validateConfig(passedConfig: OpenIdConfiguration): boolean { + return this.validateConfigInternal(passedConfig, allRules); + } + + private validateConfigsInternal( + passedConfigs: OpenIdConfiguration[], + allRulesToUse: (( + passedConfig: OpenIdConfiguration[] + ) => RuleValidationResult)[] + ): boolean { + if (passedConfigs.length === 0) { + return false; + } + + const allValidationResults = allRulesToUse.map((rule) => + rule(passedConfigs) + ); + + let overallErrorCount = 0; + + passedConfigs.forEach((passedConfig) => { + const errorCount = this.processValidationResultsAndGetErrorCount( + allValidationResults, + passedConfig + ); + + overallErrorCount += errorCount; + }); + + return overallErrorCount === 0; + } + + private validateConfigInternal( + passedConfig: OpenIdConfiguration, + allRulesToUse: (( + passedConfig: OpenIdConfiguration + ) => RuleValidationResult)[] + ): boolean { + const allValidationResults = allRulesToUse.map((rule) => + rule(passedConfig) + ); + + const errorCount = this.processValidationResultsAndGetErrorCount( + allValidationResults, + passedConfig + ); + + return errorCount === 0; + } + + private processValidationResultsAndGetErrorCount( + allValidationResults: RuleValidationResult[], + config: OpenIdConfiguration + ): number { + const allMessages = allValidationResults.filter( + (x) => x.messages.length > 0 + ); + const allErrorMessages = this.getAllMessagesOfType('error', allMessages); + const allWarnings = this.getAllMessagesOfType('warning', allMessages); + + allErrorMessages.forEach((message) => + this.loggerService.logError(config, message) + ); + allWarnings.forEach((message) => + this.loggerService.logWarning(config, message) + ); + + return allErrorMessages.length; + } + + private getAllMessagesOfType( + type: Level, + results: RuleValidationResult[] + ): string[] { + const allMessages = results + .filter((x) => x.level === type) + .map((result) => result.messages); + + return allMessages.reduce((acc, val) => acc.concat(val), []); + } +} diff --git a/src/config/validation/rule.ts b/src/config/validation/rule.ts new file mode 100644 index 0000000..69cbdd6 --- /dev/null +++ b/src/config/validation/rule.ts @@ -0,0 +1,19 @@ +import { OpenIdConfiguration } from '../openid-configuration'; + +export interface Rule { + validate(passedConfig: OpenIdConfiguration): RuleValidationResult; +} + +export interface RuleValidationResult { + result: boolean; + messages: string[]; + level: Level; +} + +export const POSITIVE_VALIDATION_RESULT: RuleValidationResult = { + result: true, + messages: [], + level: 'none', +}; + +export type Level = 'warning' | 'error' | 'none'; diff --git a/src/config/validation/rules/ensure-authority.rule.ts b/src/config/validation/rules/ensure-authority.rule.ts new file mode 100644 index 0000000..fd7fc09 --- /dev/null +++ b/src/config/validation/rules/ensure-authority.rule.ts @@ -0,0 +1,16 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +export const ensureAuthority = ( + passedConfig: OpenIdConfiguration +): RuleValidationResult => { + if (!passedConfig.authority) { + return { + result: false, + messages: ['The authority URL MUST be provided in the configuration! '], + level: 'error', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/config/validation/rules/ensure-clientId.rule.ts b/src/config/validation/rules/ensure-clientId.rule.ts new file mode 100644 index 0000000..1396bcf --- /dev/null +++ b/src/config/validation/rules/ensure-clientId.rule.ts @@ -0,0 +1,16 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +export const ensureClientId = ( + passedConfig: OpenIdConfiguration +): RuleValidationResult => { + if (!passedConfig.clientId) { + return { + result: false, + messages: ['The clientId is required and missing from your config!'], + level: 'error', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/config/validation/rules/ensure-no-duplicated-configs.rule.ts b/src/config/validation/rules/ensure-no-duplicated-configs.rule.ts new file mode 100644 index 0000000..4f9b4e7 --- /dev/null +++ b/src/config/validation/rules/ensure-no-duplicated-configs.rule.ts @@ -0,0 +1,47 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +const createIdentifierToCheck = (passedConfig: OpenIdConfiguration): string => { + if (!passedConfig) { + return ''; + } + + const { authority, clientId, scope } = passedConfig; + + return `${authority}${clientId}${scope}`; +}; + +const arrayHasDuplicates = (array: string[]): boolean => + new Set(array).size !== array.length; + +export const ensureNoDuplicatedConfigsRule = ( + passedConfigs: OpenIdConfiguration[] +): RuleValidationResult => { + const allIdentifiers = passedConfigs.map((x) => createIdentifierToCheck(x)); + + const someAreNotSet = allIdentifiers.some((x) => x === ''); + + if (someAreNotSet) { + return { + result: false, + messages: [ + `Please make sure you add an object with a 'config' property: ....({ config }) instead of ...(config)`, + ], + level: 'error', + }; + } + + const hasDuplicates = arrayHasDuplicates(allIdentifiers); + + if (hasDuplicates) { + return { + result: false, + messages: [ + 'You added multiple configs with the same authority, clientId and scope', + ], + level: 'warning', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/config/validation/rules/ensure-redirect-url.rule.ts b/src/config/validation/rules/ensure-redirect-url.rule.ts new file mode 100644 index 0000000..6cf7661 --- /dev/null +++ b/src/config/validation/rules/ensure-redirect-url.rule.ts @@ -0,0 +1,16 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +export const ensureRedirectRule = ( + passedConfig: OpenIdConfiguration +): RuleValidationResult => { + if (!passedConfig.redirectUrl) { + return { + result: false, + messages: ['The redirectUrl is required and missing from your config'], + level: 'error', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/config/validation/rules/ensure-silentRenewUrl-with-no-refreshtokens.rule.ts b/src/config/validation/rules/ensure-silentRenewUrl-with-no-refreshtokens.rule.ts new file mode 100644 index 0000000..80fe4fd --- /dev/null +++ b/src/config/validation/rules/ensure-silentRenewUrl-with-no-refreshtokens.rule.ts @@ -0,0 +1,22 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +export const ensureSilentRenewUrlWhenNoRefreshTokenUsed = ( + passedConfig: OpenIdConfiguration +): RuleValidationResult => { + const usesSilentRenew = passedConfig.silentRenew; + const usesRefreshToken = passedConfig.useRefreshToken; + const hasSilentRenewUrl = passedConfig.silentRenewUrl; + + if (usesSilentRenew && !usesRefreshToken && !hasSilentRenewUrl) { + return { + result: false, + messages: [ + 'Please provide a silent renew URL if using renew and not refresh tokens', + ], + level: 'error', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/config/validation/rules/index.ts b/src/config/validation/rules/index.ts new file mode 100644 index 0000000..fe3c8d1 --- /dev/null +++ b/src/config/validation/rules/index.ts @@ -0,0 +1,16 @@ +import { ensureAuthority } from './ensure-authority.rule'; +import { ensureClientId } from './ensure-clientId.rule'; +import { ensureNoDuplicatedConfigsRule } from './ensure-no-duplicated-configs.rule'; +import { ensureRedirectRule } from './ensure-redirect-url.rule'; +import { ensureSilentRenewUrlWhenNoRefreshTokenUsed } from './ensure-silentRenewUrl-with-no-refreshtokens.rule'; +import { useOfflineScopeWithSilentRenew } from './use-offline-scope-with-silent-renew.rule'; + +export const allRules = [ + ensureAuthority, + useOfflineScopeWithSilentRenew, + ensureRedirectRule, + ensureClientId, + ensureSilentRenewUrlWhenNoRefreshTokenUsed, +]; + +export const allMultipleConfigRules = [ensureNoDuplicatedConfigsRule]; diff --git a/src/config/validation/rules/use-offline-scope-with-silent-renew.rule.ts b/src/config/validation/rules/use-offline-scope-with-silent-renew.rule.ts new file mode 100644 index 0000000..7160573 --- /dev/null +++ b/src/config/validation/rules/use-offline-scope-with-silent-renew.rule.ts @@ -0,0 +1,23 @@ +import { OpenIdConfiguration } from '../../openid-configuration'; +import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule'; + +export const useOfflineScopeWithSilentRenew = ( + passedConfig: OpenIdConfiguration +): RuleValidationResult => { + const hasRefreshToken = passedConfig.useRefreshToken; + const hasSilentRenew = passedConfig.silentRenew; + const scope = passedConfig.scope || ''; + const hasOfflineScope = scope.split(' ').includes('offline_access'); + + if (hasRefreshToken && hasSilentRenew && !hasOfflineScope) { + return { + result: false, + messages: [ + 'When using silent renew and refresh tokens please set the `offline_access` scope', + ], + level: 'warning', + }; + } + + return POSITIVE_VALIDATION_RESULT; +}; diff --git a/src/dom/index.ts b/src/dom/index.ts new file mode 100644 index 0000000..432305d --- /dev/null +++ b/src/dom/index.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from "injection-js"; + +export const DOCUMENT = new InjectionToken('document'); \ No newline at end of file diff --git a/src/extractors/jwk.extractor.spec.ts b/src/extractors/jwk.extractor.spec.ts new file mode 100644 index 0000000..31fbdc1 --- /dev/null +++ b/src/extractors/jwk.extractor.spec.ts @@ -0,0 +1,229 @@ +import { TestBed } from '@angular/core/testing'; +import { CryptoService } from '../utils/crypto/crypto.service'; +import { + JwkExtractor, + JwkExtractorInvalidArgumentError, + JwkExtractorNoMatchingKeysError, + JwkExtractorSeveralMatchingKeysError, +} from './jwk.extractor'; + +describe('JwkExtractor', () => { + let service: JwkExtractor; + const key1 = { + kty: 'RSA', + use: 'sig', + kid: '5626CE6A8F4F5FCD79C6642345282CA76D337548RS256', + x5t: 'VibOao9PX815xmQjRSgsp20zdUg', + e: 'AQAB', + n: 'uu3-HK4pLRHJHoEBzFhM516RWx6nybG5yQjH4NbKjfGQ8dtKy1BcGjqfMaEKF8KOK44NbAx7rtBKCO9EKNYkeFvcUzBzVeuu4jWG61XYdTekgv-Dh_Fj8245GocEkbvBbFW6cw-_N59JWqUuiCvb-EOfhcuubUcr44a0AQyNccYNpcXGRcMKy7_L1YhO0AMULqLDDVLFj5glh4TcJ2N5VnJedq1-_JKOxPqD1ni26UOQoWrW16G29KZ1_4Xxf2jX8TAq-4RJEHccdzgZVIO4F5B4MucMZGq8_jMCpiTUsUGDOAMA_AmjxIRHOtO5n6Pt0wofrKoAVhGh2sCTtaQf2Q', + x5c: [ + 'MIIDPzCCAiegAwIBAgIQF+HRVxLHII9IlOoQk6BxcjANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBBzdHMuZGV2LmNlcnQuY29tMB4XDTE5MDIyMDEwMTA0M1oXDTM5MDIyMDEwMTkyOVowGzEZMBcGA1UEAwwQc3RzLmRldi5jZXJ0LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALrt/hyuKS0RyR6BAcxYTOdekVsep8mxuckIx+DWyo3xkPHbSstQXBo6nzGhChfCjiuODWwMe67QSgjvRCjWJHhb3FMwc1XrruI1hutV2HU3pIL/g4fxY/NuORqHBJG7wWxVunMPvzefSVqlLogr2/hDn4XLrm1HK+OGtAEMjXHGDaXFxkXDCsu/y9WITtADFC6iww1SxY+YJYeE3CdjeVZyXnatfvySjsT6g9Z4tulDkKFq1tehtvSmdf+F8X9o1/EwKvuESRB3HHc4GVSDuBeQeDLnDGRqvP4zAqYk1LFBgzgDAPwJo8SERzrTuZ+j7dMKH6yqAFYRodrAk7WkH9kCAwEAAaN/MH0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAtBgNVHREEJjAkghBzdHMuZGV2LmNlcnQuY29tghBzdHMuZGV2LmNlcnQuY29tMB0GA1UdDgQWBBQuyHxWP3je6jGMOmOiY+hz47r36jANBgkqhkiG9w0BAQsFAAOCAQEAKEHG7Ga6nb2XiHXDc69KsIJwbO80+LE8HVJojvITILz3juN6/FmK0HmogjU6cYST7m1MyxsVhQQNwJASZ6haBNuBbNzBXfyyfb4kr62t1oDLNwhctHaHaM4sJSf/xIw+YO+Qf7BtfRAVsbM05+QXIi2LycGrzELiXu7KFM0E1+T8UOZ2Qyv7OlCb/pWkYuDgE4w97ox0MhDpvgluxZLpRanOLUCVGrfFaij7gRAhjYPUY3vAEcD8JcFBz1XijU8ozRO6FaG4qg8/JCe+VgoWsMDj3sKB9g0ob6KCyG9L2bdk99PGgvXDQvMYCpkpZzG3XsxOINPd5p0gc209ZOoxTg==', + ], + alg: 'RS256', + } as JsonWebKey; + const key2 = { + kty: 'RSA', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key3 = { + kty: 'RSA', + use: 'enc', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key4 = { + kty: 'RSA', + use: 'sig', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key5 = { + kty: 'RSA', + use: 'sig', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key6 = { + kty: 'EC', + use: 'sig', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key7 = { + kty: 'EC', + use: 'enc', + kid: 'boop', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key8 = { + kty: 'EC', + use: 'sig', + kid: '5626CE6A8F4F5FCD79C6642345282CA76D337548RS256', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key9 = { + kty: 'EC', + use: 'sig', + kid: '5626CE6A8F4F5FCD79C6642345282CA76D337548RS256', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const keys: JsonWebKey[] = [ + key1, + key2, + key3, + key4, + key5, + key6, + key7, + key8, + key9, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [JwkExtractor, CryptoService], + }); + }); + + beforeEach(() => { + service = TestBed.inject(JwkExtractor); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('extractJwk', () => { + it('throws error if no keys are present in array', () => { + expect(() => { + service.extractJwk([]); + }).toThrow(JwkExtractorInvalidArgumentError); + }); + + it('throws error if spec.kid is present, but no key was matching', () => { + expect(() => { + service.extractJwk(keys, { kid: 'doot' }); + }).toThrow(JwkExtractorNoMatchingKeysError); + }); + + it('throws error if spec.use is present, but no key was matching', () => { + expect(() => { + service.extractJwk(keys, { use: 'blorp' }); + }).toThrow(JwkExtractorNoMatchingKeysError); + }); + + it('does not throw error if no key is matching when throwOnEmpty is false', () => { + const result = service.extractJwk(keys, { use: 'blorp' }, false); + + expect(result.length).toEqual(0); + }); + + it('throws error if multiple keys are present, and spec is not present', () => { + expect(() => { + service.extractJwk(keys); + }).toThrow(JwkExtractorSeveralMatchingKeysError); + }); + + it('returns array of keys matching spec.kid', () => { + const extracted = service.extractJwk(keys, { + kid: '5626CE6A8F4F5FCD79C6642345282CA76D337548RS256', + }); + + expect(extracted.length).toEqual(3); + expect(extracted).toContain(key1); + expect(extracted).toContain(key8); + expect(extracted).toContain(key9); + + const extracted2 = service.extractJwk(keys, { kid: 'boop' }); + + expect(extracted2.length).toEqual(6); + expect(extracted2).toContain(key2); + expect(extracted2).toContain(key3); + expect(extracted2).toContain(key4); + expect(extracted2).toContain(key5); + expect(extracted2).toContain(key6); + expect(extracted2).toContain(key7); + }); + + it('returns array of keys matching spec.use', () => { + const extracted = service.extractJwk(keys, { use: 'sig' }); + + expect(extracted.length).toEqual(6); + expect(extracted).toContain(key1); + expect(extracted).toContain(key4); + expect(extracted).toContain(key5); + expect(extracted).toContain(key6); + expect(extracted).toContain(key8); + expect(extracted).toContain(key9); + + const extracted2 = service.extractJwk(keys, { use: 'enc' }); + + expect(extracted2.length).toEqual(2); + expect(extracted2).toContain(key3); + expect(extracted2).toContain(key7); + }); + + it('returns array of keys matching the combination of spec.use and spec.kid', () => { + const extracted = service.extractJwk(keys, { kid: 'boop', use: 'sig' }); + + expect(extracted.length).toEqual(3); + expect(extracted).toContain(key4); + expect(extracted).toContain(key5); + expect(extracted).toContain(key6); + + const extracted2 = service.extractJwk(keys, { kid: 'boop', use: 'enc' }); + + expect(extracted2.length).toEqual(2); + expect(extracted2).toContain(key3); + expect(extracted2).toContain(key7); + }); + + it('returns array of keys matching spec.kty', () => { + const extracted = service.extractJwk(keys, { kty: 'RSA' }); + + expect(extracted.length).toEqual(5); + expect(extracted).toContain(key1); + expect(extracted).toContain(key2); + expect(extracted).toContain(key3); + expect(extracted).toContain(key4); + expect(extracted).toContain(key5); + + const extracted2 = service.extractJwk(keys, { kty: 'EC' }); + + expect(extracted2.length).toEqual(4); + expect(extracted2).toContain(key6); + expect(extracted2).toContain(key7); + expect(extracted2).toContain(key8); + expect(extracted2).toContain(key9); + }); + + it('returns array of keys matching the combination of spec.kty and spec.use', () => { + const extracted = service.extractJwk(keys, { kty: 'RSA', use: 'enc' }); + + expect(extracted.length).toEqual(1); + expect(extracted).toContain(key3); + + const extracted2 = service.extractJwk(keys, { kty: 'EC', use: 'sig' }); + + expect(extracted2.length).toEqual(3); + expect(extracted2).toContain(key6); + expect(extracted2).toContain(key8); + expect(extracted2).toContain(key9); + + const extracted3 = service.extractJwk(keys, { kty: 'EC', use: 'enc' }); + + expect(extracted3.length).toEqual(1); + expect(extracted3).toContain(key7); + }); + }); +}); diff --git a/src/extractors/jwk.extractor.ts b/src/extractors/jwk.extractor.ts new file mode 100644 index 0000000..ab099bf --- /dev/null +++ b/src/extractors/jwk.extractor.ts @@ -0,0 +1,48 @@ +import { Injectable } from 'injection-js'; + +@Injectable() +export class JwkExtractor { + extractJwk( + keys: JsonWebKey[], + spec?: { kid?: string; use?: string; kty?: string }, + throwOnEmpty = true + ): JsonWebKey[] { + if (0 === keys.length) { + throw JwkExtractorInvalidArgumentError; + } + + const foundKeys = keys + .filter((k) => (spec?.kid ? (k as any)['kid'] === spec.kid : true)) + .filter((k) => (spec?.use ? k['use'] === spec.use : true)) + .filter((k) => (spec?.kty ? k['kty'] === spec.kty : true)); + + if (foundKeys.length === 0 && throwOnEmpty) { + throw JwkExtractorNoMatchingKeysError; + } + + if (foundKeys.length > 1 && (null === spec || undefined === spec)) { + throw JwkExtractorSeveralMatchingKeysError; + } + + return foundKeys; + } +} + +function buildErrorName(name: string): string { + return JwkExtractor.name + ': ' + name; +} + +export const JwkExtractorInvalidArgumentError = { + name: buildErrorName('InvalidArgumentError'), + message: 'Array of keys was empty. Unable to extract', +}; + +export const JwkExtractorNoMatchingKeysError = { + name: buildErrorName('NoMatchingKeysError'), + message: 'No key found matching the spec', +}; + +export const JwkExtractorSeveralMatchingKeysError = { + name: buildErrorName('SeveralMatchingKeysError'), + message: 'More than one key found. Please use spec to filter', +}; diff --git a/src/flows/callback-context.ts b/src/flows/callback-context.ts new file mode 100644 index 0000000..87fbae0 --- /dev/null +++ b/src/flows/callback-context.ts @@ -0,0 +1,30 @@ +import { JwtKeys } from '../validation/jwtkeys'; +import { StateValidationResult } from '../validation/state-validation-result'; + +export interface CallbackContext { + code: string; + refreshToken: string; + state: string; + sessionState: string | null; + authResult: AuthResult | null; + isRenewProcess: boolean; + jwtKeys: JwtKeys | null; + validationResult: StateValidationResult | null; + existingIdToken: string | null; +} + +export interface AuthResult { + // eslint-disable-next-line @typescript-eslint/naming-convention + id_token?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + access_token?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + refresh_token?: string; + error?: any; + // eslint-disable-next-line @typescript-eslint/naming-convention + session_state?: any; + state?: any; + scope?: string; + expires_in?: number; + token_type?: string; +} diff --git a/src/flows/callback-handling/code-flow-callback-handler.service.spec.ts b/src/flows/callback-handling/code-flow-callback-handler.service.spec.ts new file mode 100644 index 0000000..d8a8108 --- /dev/null +++ b/src/flows/callback-handling/code-flow-callback-handler.service.spec.ts @@ -0,0 +1,336 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { createRetriableStream } from '../../../test/create-retriable-stream.helper'; +import { DataService } from '../../api/data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { TokenValidationService } from '../../validation/token-validation.service'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { CodeFlowCallbackHandlerService } from './code-flow-callback-handler.service'; + +describe('CodeFlowCallbackHandlerService', () => { + let service: CodeFlowCallbackHandlerService; + let dataService: DataService; + let storagePersistenceService: StoragePersistenceService; + let tokenValidationService: TokenValidationService; + let urlService: UrlService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CodeFlowCallbackHandlerService, + mockProvider(UrlService), + mockProvider(LoggerService), + mockProvider(TokenValidationService), + mockProvider(FlowsDataService), + mockProvider(StoragePersistenceService), + mockProvider(DataService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(CodeFlowCallbackHandlerService); + dataService = TestBed.inject(DataService); + urlService = TestBed.inject(UrlService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + tokenValidationService = TestBed.inject(TokenValidationService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('codeFlowCallback', () => { + it('throws error if no state is given', waitForAsync(() => { + const getUrlParameterSpy = spyOn( + urlService, + 'getUrlParameter' + ).and.returnValue('params'); + + getUrlParameterSpy.withArgs('test-url', 'state').and.returnValue(''); + + service + .codeFlowCallback('test-url', { configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('throws error if no code is given', waitForAsync(() => { + const getUrlParameterSpy = spyOn( + urlService, + 'getUrlParameter' + ).and.returnValue('params'); + + getUrlParameterSpy.withArgs('test-url', 'code').and.returnValue(''); + + service + .codeFlowCallback('test-url', { configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('returns callbackContext if all params are good', waitForAsync(() => { + spyOn(urlService, 'getUrlParameter').and.returnValue('params'); + + const expectedCallbackContext = { + code: 'params', + refreshToken: '', + state: 'params', + sessionState: 'params', + authResult: null, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + } as CallbackContext; + + service + .codeFlowCallback('test-url', { configId: 'configId1' }) + .subscribe((callbackContext) => { + expect(callbackContext).toEqual(expectedCallbackContext); + }); + })); + }); + + describe('codeFlowCodeRequest ', () => { + const HTTP_ERROR = new HttpErrorResponse({}); + const CONNECTION_ERROR = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 0, + statusText: 'Unknown Error', + url: 'https://identity-server.test/openid-connect/token', + }); + + it('throws error if state is not correct', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + service + .codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('throws error if authWellknownEndpoints is null is given', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue(null); + + service + .codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('throws error if tokenendpoint is null is given', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ tokenEndpoint: null }); + + service + .codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('calls dataService if all params are good', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue(of({})); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + service + .codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' }) + .subscribe(() => { + expect(postSpy).toHaveBeenCalledOnceWith( + 'tokenEndpoint', + undefined, + { configId: 'configId1' }, + jasmine.any(HttpHeaders) + ); + }); + })); + + it('calls url service with custom token params', waitForAsync(() => { + const urlServiceSpy = spyOn( + urlService, + 'createBodyForCodeFlowCodeRequest' + ); + const config = { + configId: 'configId1', + customParamsCodeRequest: { foo: 'bar' }, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + const postSpy = spyOn(dataService, 'post').and.returnValue(of({})); + + service + .codeFlowCodeRequest({ code: 'foo' } as CallbackContext, config) + .subscribe(() => { + expect(urlServiceSpy).toHaveBeenCalledOnceWith('foo', config, { + foo: 'bar', + }); + expect(postSpy).toHaveBeenCalledTimes(1); + }); + })); + + it('calls dataService with correct headers if all params are good', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue(of({})); + const config = { + configId: 'configId1', + customParamsCodeRequest: { foo: 'bar' }, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + service + .codeFlowCodeRequest({} as CallbackContext, config) + .subscribe(() => { + const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders; + + expect(httpHeaders.has('Content-Type')).toBeTrue(); + expect(httpHeaders.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + }); + })); + + it('returns error in case of http error', waitForAsync(() => { + spyOn(dataService, 'post').and.returnValue(throwError(() => HTTP_ERROR)); + const config = { + configId: 'configId1', + customParamsCodeRequest: { foo: 'bar' }, + authority: 'authority', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('retries request in case of no connection http error and succeeds', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => CONNECTION_ERROR), + of({}) + ) + ); + const config = { + configId: 'configId1', + customParamsCodeRequest: { foo: 'bar' }, + authority: 'authority', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(postSpy).toHaveBeenCalledTimes(1); + }, + error: (err) => { + // fails if there should be a result + expect(err).toBeFalsy(); + }, + }); + })); + + it('retries request in case of no connection http error and fails because of http error afterwards', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => CONNECTION_ERROR), + throwError(() => HTTP_ERROR) + ) + ); + const config = { + configId: 'configId1', + customParamsCodeRequest: { foo: 'bar' }, + authority: 'authority', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({ + next: (res) => { + // fails if there should be a result + expect(res).toBeFalsy(); + }, + error: (err) => { + expect(err).toBeTruthy(); + expect(postSpy).toHaveBeenCalledTimes(1); + }, + }); + })); + }); +}); diff --git a/src/flows/callback-handling/code-flow-callback-handler.service.ts b/src/flows/callback-handling/code-flow-callback-handler.service.ts new file mode 100644 index 0000000..a6af0c4 --- /dev/null +++ b/src/flows/callback-handling/code-flow-callback-handler.service.ts @@ -0,0 +1,161 @@ +import { HttpHeaders } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError, timer } from 'rxjs'; +import { catchError, mergeMap, retryWhen, switchMap } from 'rxjs/operators'; +import { DataService } from '../../api/data.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { TokenValidationService } from '../../validation/token-validation.service'; +import { AuthResult, CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { isNetworkError } from './error-helper'; + +@Injectable() +export class CodeFlowCallbackHandlerService { + private readonly urlService = inject(UrlService); + + private readonly loggerService = inject(LoggerService); + + private readonly tokenValidationService = inject(TokenValidationService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly dataService = inject(DataService); + + // STEP 1 Code Flow + codeFlowCallback( + urlToCheck: string, + config: OpenIdConfiguration + ): Observable { + const code = this.urlService.getUrlParameter(urlToCheck, 'code'); + const state = this.urlService.getUrlParameter(urlToCheck, 'state'); + const sessionState = this.urlService.getUrlParameter( + urlToCheck, + 'session_state' + ); + + if (!state) { + this.loggerService.logDebug(config, 'no state in url'); + + return throwError(() => new Error('no state in url')); + } + + if (!code) { + this.loggerService.logDebug(config, 'no code in url'); + + return throwError(() => new Error('no code in url')); + } + + this.loggerService.logDebug( + config, + 'running validation for callback', + urlToCheck + ); + + const initialCallbackContext: CallbackContext = { + code, + refreshToken: '', + state, + sessionState, + authResult: null, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + return of(initialCallbackContext); + } + + // STEP 2 Code Flow // Code Flow Silent Renew starts here + codeFlowCodeRequest( + callbackContext: CallbackContext, + config: OpenIdConfiguration + ): Observable { + const authStateControl = this.flowsDataService.getAuthStateControl(config); + const isStateCorrect = + this.tokenValidationService.validateStateFromHashCallback( + callbackContext.state, + authStateControl, + config + ); + + if (!isStateCorrect) { + return throwError(() => new Error('codeFlowCodeRequest incorrect state')); + } + + const authWellknownEndpoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + config + ); + const tokenEndpoint = authWellknownEndpoints?.tokenEndpoint; + + if (!tokenEndpoint) { + return throwError(() => new Error('Token Endpoint not defined')); + } + + let headers: HttpHeaders = new HttpHeaders(); + + headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); + + const bodyForCodeFlow = this.urlService.createBodyForCodeFlowCodeRequest( + callbackContext.code, + config, + config?.customParamsCodeRequest + ); + + return this.dataService + .post(tokenEndpoint, bodyForCodeFlow, config, headers) + .pipe( + switchMap((response) => { + if (response) { + const authResult: AuthResult = { + ...response, + state: callbackContext.state, + session_state: callbackContext.sessionState, + }; + + callbackContext.authResult = authResult; + } + + return of(callbackContext); + }), + retryWhen((error) => this.handleRefreshRetry(error, config)), + catchError((error) => { + const { authority } = config; + const errorMessage = `OidcService code request ${authority}`; + + this.loggerService.logError(config, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + private handleRefreshRetry( + errors: Observable, + config: OpenIdConfiguration + ): Observable { + return errors.pipe( + mergeMap((error) => { + // retry token refresh if there is no internet connection + if (isNetworkError(error)) { + const { authority, refreshTokenRetryInSeconds } = config; + const errorMessage = `OidcService code request ${authority} - no internet connection`; + + this.loggerService.logWarning(config, errorMessage, error); + + return timer((refreshTokenRetryInSeconds ?? 0) * 1000); + } + + return throwError(() => error); + }) + ); + } +} diff --git a/src/flows/callback-handling/error-helper.spec.ts b/src/flows/callback-handling/error-helper.spec.ts new file mode 100644 index 0000000..7c7f16d --- /dev/null +++ b/src/flows/callback-handling/error-helper.spec.ts @@ -0,0 +1,57 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { isNetworkError } from './error-helper'; + +describe('error helper', () => { + describe('isNetworkError ', () => { + const HTTP_ERROR = new HttpErrorResponse({}); + + const CONNECTION_ERROR = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 0, + statusText: 'Unknown Error', + url: 'https://identity-server.test/openid-connect/token', + }); + + const UNKNOWN_CONNECTION_ERROR = new HttpErrorResponse({ + error: new Error('error'), + status: 0, + statusText: 'Unknown Error', + url: 'https://identity-server.test/openid-connect/token', + }); + + const PARTIAL_CONNECTION_ERROR = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 418, // i am a teapot + statusText: 'Unknown Error', + url: 'https://identity-server.test/openid-connect/token', + }); + + it('returns true on http error with status = 0', () => { + expect(isNetworkError(CONNECTION_ERROR)).toBeTrue(); + }); + + it('returns true on http error with status = 0 and unknown error', () => { + expect(isNetworkError(UNKNOWN_CONNECTION_ERROR)).toBeTrue(); + }); + + it('returns true on http error with status <> 0 and error ProgressEvent', () => { + expect(isNetworkError(PARTIAL_CONNECTION_ERROR)).toBeTrue(); + }); + + it('returns false on non http error', () => { + expect(isNetworkError(new Error('not a HttpErrorResponse'))).toBeFalse(); + }); + + it('returns false on string error', () => { + expect(isNetworkError('not a HttpErrorResponse')).toBeFalse(); + }); + + it('returns false on undefined', () => { + expect(isNetworkError(undefined)).toBeFalse(); + }); + + it('returns false on empty http error', () => { + expect(isNetworkError(HTTP_ERROR)).toBeFalse(); + }); + }); +}); diff --git a/src/flows/callback-handling/error-helper.ts b/src/flows/callback-handling/error-helper.ts new file mode 100644 index 0000000..578a8db --- /dev/null +++ b/src/flows/callback-handling/error-helper.ts @@ -0,0 +1,14 @@ +import { HttpErrorResponse } from '@ngify/http'; + +/** + * checks if the error is a network error + * by checking if either internal error is a ProgressEvent with type error + * or another error with status 0 + * @param error + * @returns true if the error is a network error + */ +export const isNetworkError = (error: unknown): boolean => + !!error && + error instanceof HttpErrorResponse && + ((error.error instanceof ProgressEvent && error.error.type === 'error') || + (error.status === 0 && !!error.error)); diff --git a/src/flows/callback-handling/history-jwt-keys-callback-handler.service.spec.ts b/src/flows/callback-handling/history-jwt-keys-callback-handler.service.spec.ts new file mode 100644 index 0000000..754420c --- /dev/null +++ b/src/flows/callback-handling/history-jwt-keys-callback-handler.service.spec.ts @@ -0,0 +1,621 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { JwtKey, JwtKeys } from '../../validation/jwtkeys'; +import { ValidationResult } from '../../validation/validation-result'; +import { AuthResult, CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; +import { SigninKeyDataService } from '../signin-key-data.service'; +import { HistoryJwtKeysCallbackHandlerService } from './history-jwt-keys-callback-handler.service'; + +const DUMMY_JWT_KEYS: JwtKeys = { + keys: [ + { + kty: 'some-value1', + use: 'some-value2', + kid: 'some-value3', + x5t: 'some-value4', + e: 'some-value5', + n: 'some-value6', + x5c: ['some-value7'], + }, + ], +}; + +describe('HistoryJwtKeysCallbackHandlerService', () => { + let service: HistoryJwtKeysCallbackHandlerService; + let storagePersistenceService: StoragePersistenceService; + let signInKeyDataService: SigninKeyDataService; + let resetAuthDataService: ResetAuthDataService; + let flowsDataService: FlowsDataService; + let authStateService: AuthStateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + HistoryJwtKeysCallbackHandlerService, + mockProvider(LoggerService), + mockProvider(AuthStateService), + mockProvider(FlowsDataService), + mockProvider(SigninKeyDataService), + mockProvider(StoragePersistenceService), + mockProvider(ResetAuthDataService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(HistoryJwtKeysCallbackHandlerService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + signInKeyDataService = TestBed.inject(SigninKeyDataService); + flowsDataService = TestBed.inject(FlowsDataService); + authStateService = TestBed.inject(AuthStateService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('callbackHistoryAndResetJwtKeys', () => { + it('writes authResult into the storage', waitForAsync(() => { + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + const DUMMY_AUTH_RESULT = { + refresh_token: 'dummy_refresh_token', + id_token: 'some-id-token', + }; + + const callbackContext = { + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({ keys: [] } as JwtKeys) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe(() => { + expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([ + ['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]], + ['jwtKeys', { keys: [] }, allconfigs[0]], + ]); + // write authnResult & jwtKeys + expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2); + }); + })); + + it('writes refresh_token into the storage without reuse (refresh token rotation)', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + refresh_token: 'dummy_refresh_token', + id_token: 'some-id-token', + }; + + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + const callbackContext = { + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({ keys: [] } as JwtKeys) + ); + + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe(() => { + expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([ + ['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]], + ['jwtKeys', { keys: [] }, allconfigs[0]], + ]); + // write authnResult & refresh_token & jwtKeys + expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2); + }); + })); + + it('writes refresh_token into the storage with reuse (without refresh token rotation)', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + refresh_token: 'dummy_refresh_token', + id_token: 'some-id-token', + }; + + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + const callbackContext = { + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + allowUnsafeReuseRefreshToken: true, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({ keys: [] } as JwtKeys) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe(() => { + expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([ + ['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]], + ['reusable_refresh_token', 'dummy_refresh_token', allconfigs[0]], + ['jwtKeys', { keys: [] }, allconfigs[0]], + ]); + // write authnResult & refresh_token & jwtKeys + expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(3); + }); + })); + + it('resetBrowserHistory if historyCleanup is turned on and is not in a renewProcess', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + id_token: 'some-id-token', + }; + const callbackContext = { + isRenewProcess: false, + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: false, + }, + ]; + + const windowSpy = spyOn(window.history, 'replaceState'); + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({ keys: [] } as JwtKeys) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe(() => { + expect(windowSpy).toHaveBeenCalledTimes(1); + }); + })); + + it('returns callbackContext with jwtkeys filled if everything works fine', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + id_token: 'some-id-token', + }; + + const callbackContext = { + isRenewProcess: false, + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: false, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({ keys: [{ kty: 'henlo' } as JwtKey] } as JwtKeys) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe((result) => { + expect(result).toEqual({ + isRenewProcess: false, + authResult: DUMMY_AUTH_RESULT, + jwtKeys: { keys: [{ kty: 'henlo' }] }, + } as CallbackContext); + }); + })); + + it('returns error if no jwtKeys have been in the call --> keys are null', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + id_token: 'some-id-token', + }; + + const callbackContext = { + isRenewProcess: false, + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: false, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of({} as JwtKeys) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + error: (err) => { + expect(err.message).toEqual( + `Failed to retrieve signing key with error: Error: Failed to retrieve signing key` + ); + }, + }); + })); + + it('returns error if no jwtKeys have been in the call --> keys throw an error', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + id_token: 'some-id-token', + }; + const callbackContext = { + isRenewProcess: false, + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: false, + }, + ]; + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + throwError(() => new Error('error')) + ); + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + error: (err) => { + expect(err.message).toEqual( + `Failed to retrieve signing key with error: Error: Error: error` + ); + }, + }); + })); + + it('returns error if callbackContext.authresult has an error property filled', waitForAsync(() => { + const callbackContext = { + authResult: { error: 'someError' }, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + error: (err) => { + expect(err.message).toEqual( + `AuthCallback AuthResult came with error: someError` + ); + }, + }); + })); + + it('calls resetAuthorizationData, resets nonce and authStateService in case of an error', waitForAsync(() => { + const callbackContext = { + authResult: { error: 'someError' }, + isRenewProcess: false, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const setNonceSpy = spyOn(flowsDataService, 'setNonce'); + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + error: () => { + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1); + expect(setNonceSpy).toHaveBeenCalledTimes(1); + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + validationResult: ValidationResult.SecureTokenServerError, + isRenewProcess: false, + }); + }, + }); + })); + + it('calls authStateService.updateAndPublishAuthState with login required if the error is `login_required`', waitForAsync(() => { + const callbackContext = { + authResult: { error: 'login_required' }, + isRenewProcess: false, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const setNonceSpy = spyOn(flowsDataService, 'setNonce'); + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + + service + .callbackHistoryAndResetJwtKeys( + callbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + error: () => { + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1); + expect(setNonceSpy).toHaveBeenCalledTimes(1); + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + validationResult: ValidationResult.LoginRequired, + isRenewProcess: false, + }); + }, + }); + })); + + it('should store jwtKeys', waitForAsync(() => { + const DUMMY_AUTH_RESULT = { + id_token: 'some-id-token', + }; + + const initialCallbackContext = { + authResult: DUMMY_AUTH_RESULT, + } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + of(DUMMY_JWT_KEYS) + ); + + service + .callbackHistoryAndResetJwtKeys( + initialCallbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + next: (callbackContext: CallbackContext) => { + expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2); + expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([ + ['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]], + ['jwtKeys', DUMMY_JWT_KEYS, allconfigs[0]], + ]); + + expect(callbackContext.jwtKeys).toEqual(DUMMY_JWT_KEYS); + }, + error: (err) => { + expect(err).toBeFalsy(); + }, + }); + })); + + it('should not store jwtKeys on error', waitForAsync(() => { + const authResult = { + id_token: 'some-id-token', + access_token: 'some-access-token', + } as AuthResult; + const initialCallbackContext = { + authResult, + } as CallbackContext; + + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + throwError(() => new Error('Error')) + ); + + service + .callbackHistoryAndResetJwtKeys( + initialCallbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + next: (callbackContext: CallbackContext) => { + expect(callbackContext).toBeFalsy(); + }, + error: (err) => { + expect(err).toBeTruthy(); + + // storagePersistenceService.write() should not have been called with jwtKeys + expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith( + 'authnResult', + authResult, + allconfigs[0] + ); + }, + }); + })); + + it('should fallback to stored jwtKeys on error', waitForAsync(() => { + const authResult = { + id_token: 'some-id-token', + access_token: 'some-access-token', + } as AuthResult; + const initialCallbackContext = { + authResult, + } as CallbackContext; + + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'read' + ); + + storagePersistenceServiceSpy.and.returnValue(DUMMY_JWT_KEYS); + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + throwError(() => new Error('Error')) + ); + + service + .callbackHistoryAndResetJwtKeys( + initialCallbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + next: (callbackContext: CallbackContext) => { + expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith( + 'jwtKeys', + allconfigs[0] + ); + expect(callbackContext.jwtKeys).toEqual(DUMMY_JWT_KEYS); + }, + error: (err) => { + expect(err).toBeFalsy(); + }, + }); + })); + + it('should throw error if no jwtKeys are stored', waitForAsync(() => { + const authResult = { + id_token: 'some-id-token', + access_token: 'some-access-token', + } as AuthResult; + + const initialCallbackContext = { authResult } as CallbackContext; + const allconfigs = [ + { + configId: 'configId1', + historyCleanupOff: true, + }, + ]; + + spyOn(storagePersistenceService, 'read').and.returnValue(null); + spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue( + throwError(() => new Error('Error')) + ); + + service + .callbackHistoryAndResetJwtKeys( + initialCallbackContext, + allconfigs[0], + allconfigs + ) + .subscribe({ + next: (callbackContext: CallbackContext) => { + expect(callbackContext).toBeFalsy(); + }, + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + }); + + describe('historyCleanUpTurnedOn ', () => { + it('check for false if historyCleanUpTurnedOn is on', () => { + const config = { + configId: 'configId1', + historyCleanupOff: true, + }; + + const value = (service as any).historyCleanUpTurnedOn(config); + + expect(value).toEqual(false); + }); + + it('check for true if historyCleanUpTurnedOn is off', () => { + const config = { + configId: 'configId1', + historyCleanupOff: false, + }; + + const value = (service as any).historyCleanUpTurnedOn(config); + + expect(value).toEqual(true); + }); + }); +}); diff --git a/src/flows/callback-handling/history-jwt-keys-callback-handler.service.ts b/src/flows/callback-handling/history-jwt-keys-callback-handler.service.ts new file mode 100644 index 0000000..5dba20f --- /dev/null +++ b/src/flows/callback-handling/history-jwt-keys-callback-handler.service.ts @@ -0,0 +1,186 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { JwtKeys } from '../../validation/jwtkeys'; +import { ValidationResult } from '../../validation/validation-result'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; +import { SigninKeyDataService } from '../signin-key-data.service'; + +const JWT_KEYS = 'jwtKeys'; + +@Injectable() +export class HistoryJwtKeysCallbackHandlerService { + private readonly loggerService = inject(LoggerService); + + private readonly authStateService = inject(AuthStateService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly signInKeyDataService = inject(SigninKeyDataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly document = inject(DOCUMENT); + + // STEP 3 Code Flow, STEP 2 Implicit Flow, STEP 3 Refresh Token + callbackHistoryAndResetJwtKeys( + callbackContext: CallbackContext, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + let toWrite = { ...callbackContext.authResult }; + + if (!this.responseHasIdToken(callbackContext)) { + const existingIdToken = this.storagePersistenceService.getIdToken(config); + + toWrite = { + ...toWrite, + id_token: existingIdToken, + }; + } + + this.storagePersistenceService.write('authnResult', toWrite, config); + + if ( + config.allowUnsafeReuseRefreshToken && + callbackContext.authResult?.refresh_token + ) { + this.storagePersistenceService.write( + 'reusable_refresh_token', + callbackContext.authResult.refresh_token, + config + ); + } + + if ( + this.historyCleanUpTurnedOn(config) && + !callbackContext.isRenewProcess + ) { + this.resetBrowserHistory(); + } else { + this.loggerService.logDebug(config, 'history clean up inactive'); + } + + if (callbackContext.authResult?.error) { + const errorMessage = `AuthCallback AuthResult came with error: ${callbackContext.authResult.error}`; + + this.loggerService.logDebug(config, errorMessage); + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + this.flowsDataService.setNonce('', config); + this.handleResultErrorFromCallback( + callbackContext.authResult, + callbackContext.isRenewProcess + ); + + return throwError(() => new Error(errorMessage)); + } + + this.loggerService.logDebug( + config, + `AuthResult '${JSON.stringify(callbackContext.authResult, null, 2)}'. + AuthCallback created, begin token validation` + ); + + return this.signInKeyDataService.getSigningKeys(config).pipe( + tap((jwtKeys: JwtKeys) => this.storeSigningKeys(jwtKeys, config)), + catchError((err) => { + // fallback: try to load jwtKeys from storage + const storedJwtKeys = this.readSigningKeys(config); + + if (!!storedJwtKeys) { + this.loggerService.logWarning( + config, + `Failed to retrieve signing keys, fallback to stored keys` + ); + + return of(storedJwtKeys); + } + + return throwError(() => new Error(err)); + }), + switchMap((jwtKeys) => { + if (jwtKeys) { + callbackContext.jwtKeys = jwtKeys; + + return of(callbackContext); + } + + const errorMessage = `Failed to retrieve signing key`; + + this.loggerService.logWarning(config, errorMessage); + + return throwError(() => new Error(errorMessage)); + }), + catchError((err) => { + const errorMessage = `Failed to retrieve signing key with error: ${err}`; + + this.loggerService.logWarning(config, errorMessage); + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + private responseHasIdToken(callbackContext: CallbackContext): boolean { + return !!callbackContext?.authResult?.id_token; + } + + private handleResultErrorFromCallback( + result: unknown, + isRenewProcess: boolean + ): void { + let validationResult = ValidationResult.SecureTokenServerError; + + if ( + result && + typeof result === 'object' && + 'error' in result && + (result.error as string) === 'login_required' + ) { + validationResult = ValidationResult.LoginRequired; + } + + this.authStateService.updateAndPublishAuthState({ + isAuthenticated: false, + validationResult, + isRenewProcess, + }); + } + + private historyCleanUpTurnedOn(config: OpenIdConfiguration): boolean { + const { historyCleanupOff } = config; + + return !historyCleanupOff; + } + + private resetBrowserHistory(): void { + this.document.defaultView?.history.replaceState( + {}, + this.document.title, + this.document.defaultView.location.origin + + this.document.defaultView.location.pathname + ); + } + + private storeSigningKeys( + jwtKeys: JwtKeys, + config: OpenIdConfiguration + ): void { + this.storagePersistenceService.write(JWT_KEYS, jwtKeys, config); + } + + private readSigningKeys(config: OpenIdConfiguration): any { + return this.storagePersistenceService.read(JWT_KEYS, config); + } +} diff --git a/src/flows/callback-handling/implicit-flow-callback-handler.service.spec.ts b/src/flows/callback-handling/implicit-flow-callback-handler.service.spec.ts new file mode 100644 index 0000000..1feec31 --- /dev/null +++ b/src/flows/callback-handling/implicit-flow-callback-handler.service.spec.ts @@ -0,0 +1,142 @@ +import { DOCUMENT } from '../../dom'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { LoggerService } from '../../logging/logger.service'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; +import { ImplicitFlowCallbackHandlerService } from './implicit-flow-callback-handler.service'; + +describe('ImplicitFlowCallbackHandlerService', () => { + let service: ImplicitFlowCallbackHandlerService; + let flowsDataService: FlowsDataService; + let resetAuthDataService: ResetAuthDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ImplicitFlowCallbackHandlerService, + mockProvider(FlowsDataService), + mockProvider(ResetAuthDataService), + mockProvider(LoggerService), + { + provide: DOCUMENT, + useValue: { + location: { + get hash(): string { + return '&anyFakeHash'; + }, + set hash(_value) { + // ... + }, + }, + }, + }, + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(ImplicitFlowCallbackHandlerService); + flowsDataService = TestBed.inject(FlowsDataService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('implicitFlowCallback', () => { + it('calls "resetAuthorizationData" if silent renew is not running', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const allconfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .implicitFlowCallback(allconfigs[0], allconfigs, 'any-hash') + .subscribe(() => { + expect(resetAuthorizationDataSpy).toHaveBeenCalled(); + }); + })); + + it('does NOT calls "resetAuthorizationData" if silent renew is running', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const allconfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .implicitFlowCallback(allconfigs[0], allconfigs, 'any-hash') + .subscribe(() => { + expect(resetAuthorizationDataSpy).not.toHaveBeenCalled(); + }); + })); + + it('returns callbackContext if all params are good', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + const expectedCallbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: { anyHash: '' }, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + } as CallbackContext; + + const allconfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .implicitFlowCallback(allconfigs[0], allconfigs, 'anyHash') + .subscribe((callbackContext) => { + expect(callbackContext).toEqual(expectedCallbackContext); + }); + })); + + it('uses window location hash if no hash is passed', waitForAsync(() => { + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + const expectedCallbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: { anyFakeHash: '' }, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + } as CallbackContext; + + const allconfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .implicitFlowCallback(allconfigs[0], allconfigs) + .subscribe((callbackContext) => { + expect(callbackContext).toEqual(expectedCallbackContext); + }); + })); + }); +}); diff --git a/src/flows/callback-handling/implicit-flow-callback-handler.service.ts b/src/flows/callback-handling/implicit-flow-callback-handler.service.ts new file mode 100644 index 0000000..a676f0d --- /dev/null +++ b/src/flows/callback-handling/implicit-flow-callback-handler.service.ts @@ -0,0 +1,61 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of } from 'rxjs'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { AuthResult, CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; + +@Injectable() +export class ImplicitFlowCallbackHandlerService { + private readonly loggerService = inject(LoggerService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly document = inject(DOCUMENT); + + // STEP 1 Code Flow + // STEP 1 Implicit Flow + implicitFlowCallback( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + hash?: string + ): Observable { + const isRenewProcessData = + this.flowsDataService.isSilentRenewRunning(config); + + this.loggerService.logDebug(config, 'BEGIN callback, no auth data'); + if (!isRenewProcessData) { + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + } + + hash = hash || this.document.location.hash.substring(1); + + const authResult = hash + .split('&') + .reduce((resultData: any, item: string) => { + const parts = item.split('='); + + resultData[parts.shift() as string] = parts.join('='); + + return resultData; + }, {} as AuthResult); + + const callbackContext: CallbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult, + isRenewProcess: isRenewProcessData, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + return of(callbackContext); + } +} diff --git a/src/flows/callback-handling/refresh-session-callback-handler.service.spec.ts b/src/flows/callback-handling/refresh-session-callback-handler.service.spec.ts new file mode 100644 index 0000000..9973dc3 --- /dev/null +++ b/src/flows/callback-handling/refresh-session-callback-handler.service.spec.ts @@ -0,0 +1,82 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { LoggerService } from '../../logging/logger.service'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { RefreshSessionCallbackHandlerService } from './refresh-session-callback-handler.service'; + +describe('RefreshSessionCallbackHandlerService', () => { + let service: RefreshSessionCallbackHandlerService; + let flowsDataService: FlowsDataService; + let authStateService: AuthStateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RefreshSessionCallbackHandlerService, + mockProvider(AuthStateService), + mockProvider(LoggerService), + mockProvider(FlowsDataService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(RefreshSessionCallbackHandlerService); + flowsDataService = TestBed.inject(FlowsDataService); + authStateService = TestBed.inject(AuthStateService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('refreshSessionWithRefreshTokens', () => { + it('returns callbackContext if all params are good', waitForAsync(() => { + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('state-data'); + spyOn(authStateService, 'getRefreshToken').and.returnValue( + 'henlo-furiend' + ); + spyOn(authStateService, 'getIdToken').and.returnValue('henlo-legger'); + + const expectedCallbackContext = { + code: '', + refreshToken: 'henlo-furiend', + state: 'state-data', + sessionState: null, + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: 'henlo-legger', + } as CallbackContext; + + service + .refreshSessionWithRefreshTokens({ configId: 'configId1' }) + .subscribe((callbackContext) => { + expect(callbackContext).toEqual(expectedCallbackContext); + }); + })); + + it('throws error if no refresh token is given', waitForAsync(() => { + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('state-data'); + spyOn(authStateService, 'getRefreshToken').and.returnValue(''); + spyOn(authStateService, 'getIdToken').and.returnValue('henlo-legger'); + + service + .refreshSessionWithRefreshTokens({ configId: 'configId1' }) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + }); +}); diff --git a/src/flows/callback-handling/refresh-session-callback-handler.service.ts b/src/flows/callback-handling/refresh-session-callback-handler.service.ts new file mode 100644 index 0000000..075f1f3 --- /dev/null +++ b/src/flows/callback-handling/refresh-session-callback-handler.service.ts @@ -0,0 +1,64 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { TokenValidationService } from '../../validation/token-validation.service'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; + +@Injectable() +export class RefreshSessionCallbackHandlerService { + private readonly loggerService = inject(LoggerService); + + private readonly authStateService = inject(AuthStateService); + + private readonly flowsDataService = inject(FlowsDataService); + + // STEP 1 Refresh session + refreshSessionWithRefreshTokens( + config: OpenIdConfiguration + ): Observable { + const stateData = + this.flowsDataService.getExistingOrCreateAuthStateControl(config); + + this.loggerService.logDebug( + config, + 'RefreshSession created. Adding myautostate: ' + stateData + ); + const refreshToken = this.authStateService.getRefreshToken(config); + const idToken = this.authStateService.getIdToken(config); + + if (refreshToken) { + const callbackContext: CallbackContext = { + code: '', + refreshToken, + state: stateData, + sessionState: null, + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: idToken, + }; + + this.loggerService.logDebug( + config, + 'found refresh code, obtaining new credentials with refresh code' + ); + // Nonce is not used with refresh tokens; but Key cloak may send it anyway + this.flowsDataService.setNonce( + TokenValidationService.refreshTokenNoncePlaceholder, + config + ); + + return of(callbackContext); + } else { + const errorMessage = 'no refresh token found, please login'; + + this.loggerService.logError(config, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + } +} diff --git a/src/flows/callback-handling/refresh-token-callback-handler.service.spec.ts b/src/flows/callback-handling/refresh-token-callback-handler.service.spec.ts new file mode 100644 index 0000000..dff9ccf --- /dev/null +++ b/src/flows/callback-handling/refresh-token-callback-handler.service.spec.ts @@ -0,0 +1,178 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { createRetriableStream } from '../../../test/create-retriable-stream.helper'; +import { DataService } from '../../api/data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { CallbackContext } from '../callback-context'; +import { RefreshTokenCallbackHandlerService } from './refresh-token-callback-handler.service'; + +describe('RefreshTokenCallbackHandlerService', () => { + let service: RefreshTokenCallbackHandlerService; + let storagePersistenceService: StoragePersistenceService; + let dataService: DataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RefreshTokenCallbackHandlerService, + mockProvider(UrlService), + mockProvider(LoggerService), + mockProvider(DataService), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(RefreshTokenCallbackHandlerService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + dataService = TestBed.inject(DataService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('refreshTokensRequestTokens', () => { + const HTTP_ERROR = new HttpErrorResponse({}); + const CONNECTION_ERROR = new HttpErrorResponse({ + error: new ProgressEvent('error'), + status: 0, + statusText: 'Unknown Error', + url: 'https://identity-server.test/openid-connect/token', + }); + + it('throws error if no tokenEndpoint is given', waitForAsync(() => { + (service as any) + .refreshTokensRequestTokens({} as CallbackContext) + .subscribe({ + error: (err: unknown) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('calls data service if all params are good', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue(of({})); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service + .refreshTokensRequestTokens({} as CallbackContext, { + configId: 'configId1', + }) + .subscribe(() => { + expect(postSpy).toHaveBeenCalledOnceWith( + 'tokenEndpoint', + undefined, + { configId: 'configId1' }, + jasmine.any(HttpHeaders) + ); + const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders; + + expect(httpHeaders.has('Content-Type')).toBeTrue(); + expect(httpHeaders.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + }); + })); + + it('calls data service with correct headers if all params are good', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue(of({})); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service + .refreshTokensRequestTokens({} as CallbackContext, { + configId: 'configId1', + }) + .subscribe(() => { + const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders; + + expect(httpHeaders.has('Content-Type')).toBeTrue(); + expect(httpHeaders.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + }); + })); + + it('returns error in case of http error', waitForAsync(() => { + spyOn(dataService, 'post').and.returnValue(throwError(() => HTTP_ERROR)); + const config = { configId: 'configId1', authority: 'authority' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service + .refreshTokensRequestTokens({} as CallbackContext, config) + .subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('retries request in case of no connection http error and succeeds', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => CONNECTION_ERROR), + of({}) + ) + ); + const config = { configId: 'configId1', authority: 'authority' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service + .refreshTokensRequestTokens({} as CallbackContext, config) + .subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(postSpy).toHaveBeenCalledTimes(1); + }, + error: (err) => { + // fails if there should be a result + expect(err).toBeFalsy(); + }, + }); + })); + + it('retries request in case of no connection http error and fails because of http error afterwards', waitForAsync(() => { + const postSpy = spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => CONNECTION_ERROR), + throwError(() => HTTP_ERROR) + ) + ); + const config = { configId: 'configId1', authority: 'authority' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ tokenEndpoint: 'tokenEndpoint' }); + + service + .refreshTokensRequestTokens({} as CallbackContext, config) + .subscribe({ + next: (res) => { + // fails if there should be a result + expect(res).toBeFalsy(); + }, + error: (err) => { + expect(err).toBeTruthy(); + expect(postSpy).toHaveBeenCalledTimes(1); + }, + }); + })); + }); +}); diff --git a/src/flows/callback-handling/refresh-token-callback-handler.service.ts b/src/flows/callback-handling/refresh-token-callback-handler.service.ts new file mode 100644 index 0000000..df786f6 --- /dev/null +++ b/src/flows/callback-handling/refresh-token-callback-handler.service.ts @@ -0,0 +1,100 @@ +import { HttpHeaders } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError, timer } from 'rxjs'; +import { catchError, mergeMap, retryWhen, switchMap } from 'rxjs/operators'; +import { DataService } from '../../api/data.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { AuthResult, CallbackContext } from '../callback-context'; +import { isNetworkError } from './error-helper'; + +@Injectable() +export class RefreshTokenCallbackHandlerService { + private readonly urlService = inject(UrlService); + + private readonly loggerService = inject(LoggerService); + + private readonly dataService = inject(DataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + // STEP 2 Refresh Token + refreshTokensRequestTokens( + callbackContext: CallbackContext, + config: OpenIdConfiguration, + customParamsRefresh?: { [key: string]: string | number | boolean } + ): Observable { + let headers: HttpHeaders = new HttpHeaders(); + + headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); + + const authWellknownEndpoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + config + ); + const tokenEndpoint = authWellknownEndpoints?.tokenEndpoint; + + if (!tokenEndpoint) { + return throwError(() => new Error('Token Endpoint not defined')); + } + + const data = this.urlService.createBodyForCodeFlowRefreshTokensRequest( + callbackContext.refreshToken, + config, + customParamsRefresh + ); + + return this.dataService + .post(tokenEndpoint, data, config, headers) + .pipe( + switchMap((response) => { + this.loggerService.logDebug( + config, + `token refresh response: ${response}` + ); + + if (response) { + response.state = callbackContext.state; + } + + callbackContext.authResult = response; + + return of(callbackContext); + }), + retryWhen((error) => this.handleRefreshRetry(error, config)), + catchError((error) => { + const { authority } = config; + const errorMessage = `OidcService code request ${authority}`; + + this.loggerService.logError(config, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + private handleRefreshRetry( + errors: Observable, + config: OpenIdConfiguration + ): Observable { + return errors.pipe( + mergeMap((error) => { + // retry token refresh if there is no internet connection + if (isNetworkError(error)) { + const { authority, refreshTokenRetryInSeconds } = config; + const errorMessage = `OidcService code request ${authority} - no internet connection`; + + this.loggerService.logWarning(config, errorMessage, error); + + return timer((refreshTokenRetryInSeconds ?? 0) * 1000); + } + + return throwError(() => error); + }) + ); + } +} diff --git a/src/flows/callback-handling/state-validation-callback-handler.service.spec.ts b/src/flows/callback-handling/state-validation-callback-handler.service.spec.ts new file mode 100644 index 0000000..2bd4077 --- /dev/null +++ b/src/flows/callback-handling/state-validation-callback-handler.service.spec.ts @@ -0,0 +1,146 @@ +import { DOCUMENT } from '../../dom'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StateValidationResult } from '../../validation/state-validation-result'; +import { StateValidationService } from '../../validation/state-validation.service'; +import { ValidationResult } from '../../validation/validation-result'; +import { CallbackContext } from '../callback-context'; +import { ResetAuthDataService } from '../reset-auth-data.service'; +import { StateValidationCallbackHandlerService } from './state-validation-callback-handler.service'; + +describe('StateValidationCallbackHandlerService', () => { + let service: StateValidationCallbackHandlerService; + let stateValidationService: StateValidationService; + let loggerService: LoggerService; + let authStateService: AuthStateService; + let resetAuthDataService: ResetAuthDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + StateValidationCallbackHandlerService, + mockProvider(LoggerService), + mockProvider(StateValidationService), + mockProvider(AuthStateService), + mockProvider(ResetAuthDataService), + { + provide: DOCUMENT, + useValue: { + location: { + get hash(): string { + return '&anyFakeHash'; + }, + set hash(_value) { + // ... + }, + }, + }, + }, + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(StateValidationCallbackHandlerService); + stateValidationService = TestBed.inject(StateValidationService); + loggerService = TestBed.inject(LoggerService); + authStateService = TestBed.inject(AuthStateService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('callbackStateValidation', () => { + it('returns callbackContext with validationResult if validationResult is valid', waitForAsync(() => { + spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue( + of({ + idToken: 'idTokenJustForTesting', + authResponseIsValid: true, + } as StateValidationResult) + ); + const allConfigs = [{ configId: 'configId1' }]; + + service + .callbackStateValidation( + {} as CallbackContext, + allConfigs[0], + allConfigs + ) + .subscribe((newCallbackContext) => { + expect(newCallbackContext).toEqual({ + validationResult: { + idToken: 'idTokenJustForTesting', + authResponseIsValid: true, + }, + } as CallbackContext); + }); + })); + + it('logs error in case of an error', waitForAsync(() => { + spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue( + of({ + authResponseIsValid: false, + } as StateValidationResult) + ); + + const loggerSpy = spyOn(loggerService, 'logWarning'); + const allConfigs = [{ configId: 'configId1' }]; + + service + .callbackStateValidation( + {} as CallbackContext, + allConfigs[0], + allConfigs + ) + .subscribe({ + error: () => { + expect(loggerSpy).toHaveBeenCalledOnceWith( + allConfigs[0], + 'authorizedCallback, token(s) validation failed, resetting. Hash: &anyFakeHash' + ); + }, + }); + })); + + it('calls resetAuthDataService.resetAuthorizationData and authStateService.updateAndPublishAuthState in case of an error', waitForAsync(() => { + spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue( + of({ + authResponseIsValid: false, + state: ValidationResult.LoginRequired, + } as StateValidationResult) + ); + + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + const allConfigs = [{ configId: 'configId1' }]; + + service + .callbackStateValidation( + { isRenewProcess: true } as CallbackContext, + allConfigs[0], + allConfigs + ) + .subscribe({ + error: () => { + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1); + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + validationResult: ValidationResult.LoginRequired, + isRenewProcess: true, + }); + }, + }); + })); + }); +}); diff --git a/src/flows/callback-handling/state-validation-callback-handler.service.ts b/src/flows/callback-handling/state-validation-callback-handler.service.ts new file mode 100644 index 0000000..bc4c828 --- /dev/null +++ b/src/flows/callback-handling/state-validation-callback-handler.service.ts @@ -0,0 +1,75 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StateValidationResult } from '../../validation/state-validation-result'; +import { StateValidationService } from '../../validation/state-validation.service'; +import { CallbackContext } from '../callback-context'; +import { ResetAuthDataService } from '../reset-auth-data.service'; + +@Injectable() +export class StateValidationCallbackHandlerService { + private readonly loggerService = inject(LoggerService); + + private readonly stateValidationService = inject(StateValidationService); + + private readonly authStateService = inject(AuthStateService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly document = inject(DOCUMENT); + + // STEP 4 All flows + callbackStateValidation( + callbackContext: CallbackContext, + configuration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + return this.stateValidationService + .getValidatedStateResult(callbackContext, configuration) + .pipe( + map((validationResult: StateValidationResult) => { + callbackContext.validationResult = validationResult; + + if (validationResult.authResponseIsValid) { + this.authStateService.setAuthorizationData( + validationResult.accessToken, + callbackContext.authResult, + configuration, + allConfigs + ); + + return callbackContext; + } else { + const errorMessage = `authorizedCallback, token(s) validation failed, resetting. Hash: ${this.document.location.hash}`; + + this.loggerService.logWarning(configuration, errorMessage); + this.resetAuthDataService.resetAuthorizationData( + configuration, + allConfigs + ); + this.publishUnauthorizedState( + callbackContext.validationResult, + callbackContext.isRenewProcess + ); + + throw new Error(errorMessage); + } + }) + ); + } + + private publishUnauthorizedState( + stateValidationResult: StateValidationResult, + isRenewProcess: boolean + ): void { + this.authStateService.updateAndPublishAuthState({ + isAuthenticated: false, + validationResult: stateValidationResult.state, + isRenewProcess, + }); + } +} diff --git a/src/flows/callback-handling/user-callback-handler.service.spec.ts b/src/flows/callback-handling/user-callback-handler.service.spec.ts new file mode 100644 index 0000000..5696a6c --- /dev/null +++ b/src/flows/callback-handling/user-callback-handler.service.spec.ts @@ -0,0 +1,457 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { LoggerService } from '../../logging/logger.service'; +import { UserService } from '../../user-data/user.service'; +import { StateValidationResult } from '../../validation/state-validation-result'; +import { ValidationResult } from '../../validation/validation-result'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; +import { UserCallbackHandlerService } from './user-callback-handler.service'; + +describe('UserCallbackHandlerService', () => { + let service: UserCallbackHandlerService; + let authStateService: AuthStateService; + let flowsDataService: FlowsDataService; + let userService: UserService; + let resetAuthDataService: ResetAuthDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + UserCallbackHandlerService, + mockProvider(LoggerService), + mockProvider(AuthStateService), + mockProvider(FlowsDataService), + mockProvider(UserService), + mockProvider(ResetAuthDataService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(UserCallbackHandlerService); + flowsDataService = TestBed.inject(FlowsDataService); + authStateService = TestBed.inject(AuthStateService); + userService = TestBed.inject(UserService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('callbackUser', () => { + it('calls flowsDataService.setSessionState with correct params if autoUserInfo is false, isRenewProcess is false and refreshToken is null', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded' + ); + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: '', + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: false, + }, + ]; + + const spy = spyOn(flowsDataService, 'setSessionState'); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(spy).toHaveBeenCalledOnceWith('mystate', allConfigs[0]); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('does NOT call flowsDataService.setSessionState if autoUserInfo is false, isRenewProcess is true and refreshToken is null', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded' + ); + const callbackContext = { + code: '', + refreshToken: '', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: true, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: false, + }, + ]; + const spy = spyOn(flowsDataService, 'setSessionState'); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(spy).not.toHaveBeenCalled(); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('does NOT call flowsDataService.setSessionState if autoUserInfo is false isRenewProcess is false, refreshToken has value', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded' + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: false, + }, + ]; + const spy = spyOn(flowsDataService, 'setSessionState'); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(spy).not.toHaveBeenCalled(); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('does NOT call flowsDataService.setSessionState if autoUserInfo is false isRenewProcess is false, refreshToken has value, id_token is false', waitForAsync(() => { + const svr = new StateValidationResult('accesstoken', '', true, ''); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: false, + }, + ]; + + const spy = spyOn(flowsDataService, 'setSessionState'); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(spy).not.toHaveBeenCalled(); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('calls authStateService.updateAndPublishAuthState with correct params if autoUserInfo is false', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded' + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: false, + }, + ]; + + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: true, + validationResult: ValidationResult.NotSet, + isRenewProcess: false, + }); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('calls userService.getAndPersistUserDataInStore with correct params if autoUserInfo is true', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded' + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: true, + }, + ]; + + const getAndPersistUserDataInStoreSpy = spyOn( + userService, + 'getAndPersistUserDataInStore' + ).and.returnValue(of({ user: 'some_data' })); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(getAndPersistUserDataInStoreSpy).toHaveBeenCalledOnceWith( + allConfigs[0], + allConfigs, + false, + 'idtoken', + 'decoded' + ); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('calls authStateService.updateAndPublishAuthState with correct params if autoUserInfo is true', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded', + ValidationResult.MaxOffsetExpired + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: true, + }, + ]; + + spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue( + of({ user: 'some_data' }) + ); + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: true, + validationResult: ValidationResult.MaxOffsetExpired, + isRenewProcess: false, + }); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('calls flowsDataService.setSessionState with correct params if user data is present and NOT refresh token', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded', + ValidationResult.MaxOffsetExpired + ); + const callbackContext = { + code: '', + refreshToken: '', // something falsy + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: true, + }, + ]; + + spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue( + of({ user: 'some_data' }) + ); + const setSessionStateSpy = spyOn(flowsDataService, 'setSessionState'); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe((resultCallbackContext) => { + expect(setSessionStateSpy).toHaveBeenCalledOnceWith( + 'mystate', + allConfigs[0] + ); + expect(resultCallbackContext).toEqual(callbackContext); + }); + })); + + it('calls authStateService.publishUnauthorizedState with correct params if user info which are coming back are null', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded', + ValidationResult.MaxOffsetExpired + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: true, + }, + ]; + + spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue( + of(null) + ); + const updateAndPublishAuthStateSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe({ + error: (err) => { + expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + validationResult: ValidationResult.MaxOffsetExpired, + isRenewProcess: false, + }); + expect(err.message).toEqual( + 'Failed to retrieve user info with error: Error: Called for userData but they were null' + ); + }, + }); + })); + + it('calls resetAuthDataService.resetAuthorizationData if user info which are coming back are null', waitForAsync(() => { + const svr = new StateValidationResult( + 'accesstoken', + 'idtoken', + true, + 'decoded', + ValidationResult.MaxOffsetExpired + ); + const callbackContext = { + code: '', + refreshToken: 'somerefreshtoken', + state: '', + sessionState: null, + authResult: { session_state: 'mystate' }, + isRenewProcess: false, + jwtKeys: null, + validationResult: svr, + existingIdToken: null, + } as CallbackContext; + + const allConfigs = [ + { + configId: 'configId1', + autoUserInfo: true, + }, + ]; + + spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue( + of(null) + ); + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + + service + .callbackUser(callbackContext, allConfigs[0], allConfigs) + .subscribe({ + error: (err) => { + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1); + expect(err.message).toEqual( + 'Failed to retrieve user info with error: Error: Called for userData but they were null' + ); + }, + }); + })); + }); +}); diff --git a/src/flows/callback-handling/user-callback-handler.service.ts b/src/flows/callback-handling/user-callback-handler.service.ts new file mode 100644 index 0000000..ea0fa10 --- /dev/null +++ b/src/flows/callback-handling/user-callback-handler.service.ts @@ -0,0 +1,132 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { AuthStateService } from '../../auth-state/auth-state.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { UserService } from '../../user-data/user.service'; +import { StateValidationResult } from '../../validation/state-validation-result'; +import { CallbackContext } from '../callback-context'; +import { FlowsDataService } from '../flows-data.service'; +import { ResetAuthDataService } from '../reset-auth-data.service'; + +@Injectable() +export class UserCallbackHandlerService { + private readonly loggerService = inject(LoggerService); + + private readonly authStateService = inject(AuthStateService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly userService = inject(UserService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + // STEP 5 userData + callbackUser( + callbackContext: CallbackContext, + configuration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const { isRenewProcess, validationResult, authResult, refreshToken } = + callbackContext; + const { autoUserInfo, renewUserInfoAfterTokenRenew } = configuration; + + if (!autoUserInfo) { + if (!isRenewProcess || renewUserInfoAfterTokenRenew) { + // userData is set to the id_token decoded, auto get user data set to false + if (validationResult?.decodedIdToken) { + this.userService.setUserDataToStore( + validationResult.decodedIdToken, + configuration, + allConfigs + ); + } + } + + if (!isRenewProcess && !refreshToken) { + this.flowsDataService.setSessionState( + authResult?.session_state, + configuration + ); + } + + this.publishAuthState(validationResult, isRenewProcess); + + return of(callbackContext); + } + + return this.userService + .getAndPersistUserDataInStore( + configuration, + allConfigs, + isRenewProcess, + validationResult?.idToken, + validationResult?.decodedIdToken + ) + .pipe( + switchMap((userData) => { + if (!!userData) { + if (!refreshToken) { + this.flowsDataService.setSessionState( + authResult?.session_state, + configuration + ); + } + + this.publishAuthState(validationResult, isRenewProcess); + + return of(callbackContext); + } else { + this.resetAuthDataService.resetAuthorizationData( + configuration, + allConfigs + ); + this.publishUnauthenticatedState(validationResult, isRenewProcess); + const errorMessage = `Called for userData but they were ${userData}`; + + this.loggerService.logWarning(configuration, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + }), + catchError((err) => { + const errorMessage = `Failed to retrieve user info with error: ${err}`; + + this.loggerService.logWarning(configuration, errorMessage); + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + private publishAuthState( + stateValidationResult: StateValidationResult | null, + isRenewProcess: boolean + ): void { + if (!stateValidationResult) { + return; + } + + this.authStateService.updateAndPublishAuthState({ + isAuthenticated: true, + validationResult: stateValidationResult.state, + isRenewProcess, + }); + } + + private publishUnauthenticatedState( + stateValidationResult: StateValidationResult | null, + isRenewProcess: boolean + ): void { + if (!stateValidationResult) { + return; + } + + this.authStateService.updateAndPublishAuthState({ + isAuthenticated: false, + validationResult: stateValidationResult.state, + isRenewProcess, + }); + } +} diff --git a/src/flows/flows-data.service.spec.ts b/src/flows/flows-data.service.spec.ts new file mode 100644 index 0000000..dc8e258 --- /dev/null +++ b/src/flows/flows-data.service.spec.ts @@ -0,0 +1,333 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { CryptoService } from '../utils/crypto/crypto.service'; +import { FlowsDataService } from './flows-data.service'; +import { RandomService } from './random/random.service'; + +describe('Flows Data Service', () => { + let service: FlowsDataService; + let storagePersistenceService: StoragePersistenceService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + FlowsDataService, + RandomService, + CryptoService, + mockProvider(LoggerService), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(FlowsDataService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('createNonce', () => { + it('createNonce returns nonce and stores it', () => { + const spy = spyOn(storagePersistenceService, 'write'); + + const result = service.createNonce({ configId: 'configId1' }); + + expect(result).toBeTruthy(); + expect(spy).toHaveBeenCalledOnceWith('authNonce', result, { + configId: 'configId1', + }); + }); + }); + + describe('AuthStateControl', () => { + it('getAuthStateControl returns property from store', () => { + const spy = spyOn(storagePersistenceService, 'read'); + + service.getAuthStateControl({ configId: 'configId1' }); + + expect(spy).toHaveBeenCalledOnceWith('authStateControl', { + configId: 'configId1', + }); + }); + + it('setAuthStateControl saves property in store', () => { + const spy = spyOn(storagePersistenceService, 'write'); + + service.setAuthStateControl('ToSave', { configId: 'configId1' }); + + expect(spy).toHaveBeenCalledOnceWith('authStateControl', 'ToSave', { + configId: 'configId1', + }); + }); + }); + + describe('getExistingOrCreateAuthStateControl', () => { + it('if nothing stored it creates a 40 char one and saves the authStateControl', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('authStateControl', { configId: 'configId1' }) + .and.returnValue(null); + const setSpy = spyOn(storagePersistenceService, 'write'); + + const result = service.getExistingOrCreateAuthStateControl({ + configId: 'configId1', + }); + + expect(result).toBeTruthy(); + expect(result.length).toBe(41); + expect(setSpy).toHaveBeenCalledOnceWith('authStateControl', result, { + configId: 'configId1', + }); + }); + + it('if stored it returns the value and does NOT Store the value again', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('authStateControl', { configId: 'configId1' }) + .and.returnValue('someAuthStateControl'); + const setSpy = spyOn(storagePersistenceService, 'write'); + + const result = service.getExistingOrCreateAuthStateControl({ + configId: 'configId1', + }); + + expect(result).toEqual('someAuthStateControl'); + expect(result.length).toBe('someAuthStateControl'.length); + expect(setSpy).not.toHaveBeenCalled(); + }); + }); + + describe('setSessionState', () => { + it('setSessionState saves the value in the storage', () => { + const spy = spyOn(storagePersistenceService, 'write'); + + service.setSessionState('Genesis', { configId: 'configId1' }); + + expect(spy).toHaveBeenCalledOnceWith('session_state', 'Genesis', { + configId: 'configId1', + }); + }); + }); + + describe('resetStorageFlowData', () => { + it('resetStorageFlowData calls correct method on storagePersistenceService', () => { + const spy = spyOn(storagePersistenceService, 'resetStorageFlowData'); + + service.resetStorageFlowData({ configId: 'configId1' }); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('codeVerifier', () => { + it('getCodeVerifier returns value from the store', () => { + const spy = spyOn(storagePersistenceService, 'read') + .withArgs('codeVerifier', { configId: 'configId1' }) + .and.returnValue('Genesis'); + + const result = service.getCodeVerifier({ configId: 'configId1' }); + + expect(result).toBe('Genesis'); + expect(spy).toHaveBeenCalledOnceWith('codeVerifier', { + configId: 'configId1', + }); + }); + + it('createCodeVerifier returns random createCodeVerifier and stores it', () => { + const setSpy = spyOn(storagePersistenceService, 'write'); + + const result = service.createCodeVerifier({ configId: 'configId1' }); + + expect(result).toBeTruthy(); + expect(result.length).toBe(67); + expect(setSpy).toHaveBeenCalledOnceWith('codeVerifier', result, { + configId: 'configId1', + }); + }); + }); + + describe('isCodeFlowInProgress', () => { + it('checks code flow is in progress and returns result', () => { + const config = { + configId: 'configId1', + }; + + jasmine.clock().uninstall(); + jasmine.clock().install(); + const baseTime = new Date(); + + jasmine.clock().mockDate(baseTime); + + spyOn(storagePersistenceService, 'read') + .withArgs('storageCodeFlowInProgress', config) + .and.returnValue(true); + const spyWrite = spyOn(storagePersistenceService, 'write'); + + const isCodeFlowInProgressResult = service.isCodeFlowInProgress(config); + + expect(spyWrite).not.toHaveBeenCalled(); + expect(isCodeFlowInProgressResult).toBeTrue(); + }); + + it('state object does not exist returns false result', () => { + // arrange + spyOn(storagePersistenceService, 'read') + .withArgs('storageCodeFlowInProgress', { configId: 'configId1' }) + .and.returnValue(null); + + // act + const isCodeFlowInProgressResult = service.isCodeFlowInProgress({ + configId: 'configId1', + }); + + // assert + expect(isCodeFlowInProgressResult).toBeFalse(); + }); + }); + + describe('setCodeFlowInProgress', () => { + it('set setCodeFlowInProgress to `in progress` when called', () => { + jasmine.clock().uninstall(); + jasmine.clock().install(); + const baseTime = new Date(); + + jasmine.clock().mockDate(baseTime); + + const spy = spyOn(storagePersistenceService, 'write'); + + service.setCodeFlowInProgress({ configId: 'configId1' }); + expect(spy).toHaveBeenCalledOnceWith('storageCodeFlowInProgress', true, { + configId: 'configId1', + }); + }); + }); + + describe('resetCodeFlowInProgress', () => { + it('set resetCodeFlowInProgress to false when called', () => { + const spy = spyOn(storagePersistenceService, 'write'); + + service.resetCodeFlowInProgress({ configId: 'configId1' }); + expect(spy).toHaveBeenCalledOnceWith('storageCodeFlowInProgress', false, { + configId: 'configId1', + }); + }); + }); + + describe('isSilentRenewRunning', () => { + it('silent renew process timeout exceeded reset state object and returns false result', () => { + const config = { + silentRenewTimeoutInSeconds: 10, + configId: 'configId1', + }; + + jasmine.clock().uninstall(); + jasmine.clock().install(); + const baseTime = new Date(); + + jasmine.clock().mockDate(baseTime); + + const storageObject = { + state: 'running', + dateOfLaunchedProcessUtc: baseTime.toISOString(), + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('storageSilentRenewRunning', config) + .and.returnValue(JSON.stringify(storageObject)); + const spyWrite = spyOn(storagePersistenceService, 'write'); + + jasmine.clock().tick((config.silentRenewTimeoutInSeconds + 1) * 1000); + + const isSilentRenewRunningResult = service.isSilentRenewRunning(config); + + expect(spyWrite).toHaveBeenCalledOnceWith( + 'storageSilentRenewRunning', + '', + config + ); + expect(isSilentRenewRunningResult).toBeFalse(); + }); + + it('checks silent renew process and returns result', () => { + const config = { + silentRenewTimeoutInSeconds: 10, + configId: 'configId1', + }; + + jasmine.clock().uninstall(); + jasmine.clock().install(); + const baseTime = new Date(); + + jasmine.clock().mockDate(baseTime); + + const storageObject = { + state: 'running', + dateOfLaunchedProcessUtc: baseTime.toISOString(), + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('storageSilentRenewRunning', config) + .and.returnValue(JSON.stringify(storageObject)); + const spyWrite = spyOn(storagePersistenceService, 'write'); + + const isSilentRenewRunningResult = service.isSilentRenewRunning(config); + + expect(spyWrite).not.toHaveBeenCalled(); + expect(isSilentRenewRunningResult).toBeTrue(); + }); + + it('state object does not exist returns false result', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('storageSilentRenewRunning', { configId: 'configId1' }) + .and.returnValue(null); + + const isSilentRenewRunningResult = service.isSilentRenewRunning({ + configId: 'configId1', + }); + + expect(isSilentRenewRunningResult).toBeFalse(); + }); + }); + + describe('setSilentRenewRunning', () => { + it('set setSilentRenewRunning to `running` with lauched time when called', () => { + jasmine.clock().uninstall(); + jasmine.clock().install(); + const baseTime = new Date(); + + jasmine.clock().mockDate(baseTime); + + const storageObject = { + state: 'running', + dateOfLaunchedProcessUtc: baseTime.toISOString(), + }; + + const spy = spyOn(storagePersistenceService, 'write'); + + service.setSilentRenewRunning({ configId: 'configId1' }); + expect(spy).toHaveBeenCalledOnceWith( + 'storageSilentRenewRunning', + JSON.stringify(storageObject), + { configId: 'configId1' } + ); + }); + }); + + describe('resetSilentRenewRunning', () => { + it('set resetSilentRenewRunning to empty string when called', () => { + const spy = spyOn(storagePersistenceService, 'write'); + + service.resetSilentRenewRunning({ configId: 'configId1' }); + expect(spy).toHaveBeenCalledOnceWith('storageSilentRenewRunning', '', { + configId: 'configId1', + }); + }); + }); +}); diff --git a/src/flows/flows-data.service.ts b/src/flows/flows-data.service.ts new file mode 100644 index 0000000..488ef37 --- /dev/null +++ b/src/flows/flows-data.service.ts @@ -0,0 +1,204 @@ +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { SilentRenewRunning } from './flows.models'; +import { RandomService } from './random/random.service'; + +@Injectable() +export class FlowsDataService { + private readonly loggerService = inject(LoggerService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly randomService = inject(RandomService); + + createNonce(configuration: OpenIdConfiguration): string { + const nonce = this.randomService.createRandom(40, configuration); + + this.loggerService.logDebug(configuration, 'Nonce created. nonce:' + nonce); + this.setNonce(nonce, configuration); + + return nonce; + } + + setNonce(nonce: string, configuration: OpenIdConfiguration): void { + this.storagePersistenceService.write('authNonce', nonce, configuration); + } + + getAuthStateControl(configuration: OpenIdConfiguration | null): string { + if (!configuration) { + return ''; + } + + return this.storagePersistenceService.read( + 'authStateControl', + configuration + ); + } + + setAuthStateControl( + authStateControl: string, + configuration: OpenIdConfiguration | null + ): boolean { + if (!configuration) { + return false; + } + + return this.storagePersistenceService.write( + 'authStateControl', + authStateControl, + configuration + ); + } + + getExistingOrCreateAuthStateControl(configuration: OpenIdConfiguration): any { + let state = this.storagePersistenceService.read( + 'authStateControl', + configuration + ); + + if (!state) { + state = this.randomService.createRandom(40, configuration); + this.storagePersistenceService.write( + 'authStateControl', + state, + configuration + ); + } + + return state; + } + + setSessionState(sessionState: any, configuration: OpenIdConfiguration): void { + this.storagePersistenceService.write( + 'session_state', + sessionState, + configuration + ); + } + + resetStorageFlowData(configuration: OpenIdConfiguration): void { + this.storagePersistenceService.resetStorageFlowData(configuration); + } + + getCodeVerifier(configuration: OpenIdConfiguration): any { + return this.storagePersistenceService.read('codeVerifier', configuration); + } + + createCodeVerifier(configuration: OpenIdConfiguration): string { + const codeVerifier = this.randomService.createRandom(67, configuration); + + this.storagePersistenceService.write( + 'codeVerifier', + codeVerifier, + configuration + ); + + return codeVerifier; + } + + isCodeFlowInProgress(configuration: OpenIdConfiguration): boolean { + return !!this.storagePersistenceService.read( + 'storageCodeFlowInProgress', + configuration + ); + } + + setCodeFlowInProgress(configuration: OpenIdConfiguration): void { + this.storagePersistenceService.write( + 'storageCodeFlowInProgress', + true, + configuration + ); + } + + resetCodeFlowInProgress(configuration: OpenIdConfiguration): void { + this.storagePersistenceService.write( + 'storageCodeFlowInProgress', + false, + configuration + ); + } + + isSilentRenewRunning(configuration: OpenIdConfiguration): boolean { + const { configId, silentRenewTimeoutInSeconds } = configuration; + const storageObject = this.getSilentRenewRunningStorageEntry(configuration); + + if (!storageObject) { + return false; + } + + if (storageObject.state === 'not-running') { + return false; + } + + const timeOutInMilliseconds = (silentRenewTimeoutInSeconds ?? 0) * 1000; + const dateOfLaunchedProcessUtc = Date.parse( + storageObject.dateOfLaunchedProcessUtc + ); + const currentDateUtc = Date.parse(new Date().toISOString()); + const elapsedTimeInMilliseconds = Math.abs( + currentDateUtc - dateOfLaunchedProcessUtc + ); + const isProbablyStuck = elapsedTimeInMilliseconds > timeOutInMilliseconds; + + if (isProbablyStuck) { + this.loggerService.logDebug( + configuration, + 'silent renew process is probably stuck, state will be reset.', + configId + ); + this.resetSilentRenewRunning(configuration); + + return false; + } + + return storageObject.state === 'running'; + } + + setSilentRenewRunning(configuration: OpenIdConfiguration): void { + const storageObject: SilentRenewRunning = { + state: 'running', + dateOfLaunchedProcessUtc: new Date().toISOString(), + }; + + this.storagePersistenceService.write( + 'storageSilentRenewRunning', + JSON.stringify(storageObject), + configuration + ); + } + + resetSilentRenewRunning(configuration: OpenIdConfiguration | null): void { + if (!configuration) { + return; + } + + this.storagePersistenceService.write( + 'storageSilentRenewRunning', + '', + configuration + ); + } + + private getSilentRenewRunningStorageEntry( + configuration: OpenIdConfiguration + ): SilentRenewRunning { + const storageEntry = this.storagePersistenceService.read( + 'storageSilentRenewRunning', + configuration + ); + + if (!storageEntry) { + return { + dateOfLaunchedProcessUtc: '', + state: 'not-running', + }; + } + + return JSON.parse(storageEntry); + } +} diff --git a/src/flows/flows.models.ts b/src/flows/flows.models.ts new file mode 100644 index 0000000..f1089c8 --- /dev/null +++ b/src/flows/flows.models.ts @@ -0,0 +1,6 @@ +export interface SilentRenewRunning { + state: SilentRenewRunningState; + dateOfLaunchedProcessUtc: string; +} + +export type SilentRenewRunningState = 'running' | 'not-running'; diff --git a/src/flows/flows.service.spec.ts b/src/flows/flows.service.spec.ts new file mode 100644 index 0000000..34c33ce --- /dev/null +++ b/src/flows/flows.service.spec.ts @@ -0,0 +1,226 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { CallbackContext } from './callback-context'; +import { CodeFlowCallbackHandlerService } from './callback-handling/code-flow-callback-handler.service'; +import { HistoryJwtKeysCallbackHandlerService } from './callback-handling/history-jwt-keys-callback-handler.service'; +import { ImplicitFlowCallbackHandlerService } from './callback-handling/implicit-flow-callback-handler.service'; +import { RefreshSessionCallbackHandlerService } from './callback-handling/refresh-session-callback-handler.service'; +import { RefreshTokenCallbackHandlerService } from './callback-handling/refresh-token-callback-handler.service'; +import { StateValidationCallbackHandlerService } from './callback-handling/state-validation-callback-handler.service'; +import { UserCallbackHandlerService } from './callback-handling/user-callback-handler.service'; +import { FlowsService } from './flows.service'; + +describe('Flows Service', () => { + let service: FlowsService; + let codeFlowCallbackHandlerService: CodeFlowCallbackHandlerService; + let implicitFlowCallbackHandlerService: ImplicitFlowCallbackHandlerService; + let historyJwtKeysCallbackHandlerService: HistoryJwtKeysCallbackHandlerService; + let userCallbackHandlerService: UserCallbackHandlerService; + let stateValidationCallbackHandlerService: StateValidationCallbackHandlerService; + let refreshSessionCallbackHandlerService: RefreshSessionCallbackHandlerService; + let refreshTokenCallbackHandlerService: RefreshTokenCallbackHandlerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + FlowsService, + mockProvider(CodeFlowCallbackHandlerService), + mockProvider(ImplicitFlowCallbackHandlerService), + mockProvider(HistoryJwtKeysCallbackHandlerService), + mockProvider(UserCallbackHandlerService), + mockProvider(StateValidationCallbackHandlerService), + mockProvider(RefreshSessionCallbackHandlerService), + mockProvider(RefreshTokenCallbackHandlerService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(FlowsService); + codeFlowCallbackHandlerService = TestBed.inject( + CodeFlowCallbackHandlerService + ); + implicitFlowCallbackHandlerService = TestBed.inject( + ImplicitFlowCallbackHandlerService + ); + historyJwtKeysCallbackHandlerService = TestBed.inject( + HistoryJwtKeysCallbackHandlerService + ); + userCallbackHandlerService = TestBed.inject(UserCallbackHandlerService); + stateValidationCallbackHandlerService = TestBed.inject( + StateValidationCallbackHandlerService + ); + refreshSessionCallbackHandlerService = TestBed.inject( + RefreshSessionCallbackHandlerService + ); + refreshTokenCallbackHandlerService = TestBed.inject( + RefreshTokenCallbackHandlerService + ); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('processCodeFlowCallback', () => { + it('calls all methods correctly', waitForAsync(() => { + const codeFlowCallbackSpy = spyOn( + codeFlowCallbackHandlerService, + 'codeFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const codeFlowCodeRequestSpy = spyOn( + codeFlowCallbackHandlerService, + 'codeFlowCodeRequest' + ).and.returnValue(of({} as CallbackContext)); + const callbackHistoryAndResetJwtKeysSpy = spyOn( + historyJwtKeysCallbackHandlerService, + 'callbackHistoryAndResetJwtKeys' + ).and.returnValue(of({} as CallbackContext)); + const callbackStateValidationSpy = spyOn( + stateValidationCallbackHandlerService, + 'callbackStateValidation' + ).and.returnValue(of({} as CallbackContext)); + const callbackUserSpy = spyOn( + userCallbackHandlerService, + 'callbackUser' + ).and.returnValue(of({} as CallbackContext)); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .processCodeFlowCallback('some-url1234', allConfigs[0], allConfigs) + .subscribe((value) => { + expect(value).toEqual({} as CallbackContext); + expect(codeFlowCallbackSpy).toHaveBeenCalledOnceWith( + 'some-url1234', + allConfigs[0] + ); + expect(codeFlowCodeRequestSpy).toHaveBeenCalledTimes(1); + expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalledTimes(1); + expect(callbackStateValidationSpy).toHaveBeenCalledTimes(1); + expect(callbackUserSpy).toHaveBeenCalledTimes(1); + }); + })); + }); + + describe('processSilentRenewCodeFlowCallback', () => { + it('calls all methods correctly', waitForAsync(() => { + const codeFlowCodeRequestSpy = spyOn( + codeFlowCallbackHandlerService, + 'codeFlowCodeRequest' + ).and.returnValue(of({} as CallbackContext)); + const callbackHistoryAndResetJwtKeysSpy = spyOn( + historyJwtKeysCallbackHandlerService, + 'callbackHistoryAndResetJwtKeys' + ).and.returnValue(of({} as CallbackContext)); + const callbackStateValidationSpy = spyOn( + stateValidationCallbackHandlerService, + 'callbackStateValidation' + ).and.returnValue(of({} as CallbackContext)); + const callbackUserSpy = spyOn( + userCallbackHandlerService, + 'callbackUser' + ).and.returnValue(of({} as CallbackContext)); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .processSilentRenewCodeFlowCallback( + {} as CallbackContext, + allConfigs[0], + allConfigs + ) + .subscribe((value) => { + expect(value).toEqual({} as CallbackContext); + expect(codeFlowCodeRequestSpy).toHaveBeenCalled(); + expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled(); + expect(callbackStateValidationSpy).toHaveBeenCalled(); + expect(callbackUserSpy).toHaveBeenCalled(); + }); + })); + }); + + describe('processImplicitFlowCallback', () => { + it('calls all methods correctly', waitForAsync(() => { + const implicitFlowCallbackSpy = spyOn( + implicitFlowCallbackHandlerService, + 'implicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const callbackHistoryAndResetJwtKeysSpy = spyOn( + historyJwtKeysCallbackHandlerService, + 'callbackHistoryAndResetJwtKeys' + ).and.returnValue(of({} as CallbackContext)); + const callbackStateValidationSpy = spyOn( + stateValidationCallbackHandlerService, + 'callbackStateValidation' + ).and.returnValue(of({} as CallbackContext)); + const callbackUserSpy = spyOn( + userCallbackHandlerService, + 'callbackUser' + ).and.returnValue(of({} as CallbackContext)); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .processImplicitFlowCallback(allConfigs[0], allConfigs, 'any-hash') + .subscribe((value) => { + expect(value).toEqual({} as CallbackContext); + expect(implicitFlowCallbackSpy).toHaveBeenCalled(); + expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled(); + expect(callbackStateValidationSpy).toHaveBeenCalled(); + expect(callbackUserSpy).toHaveBeenCalled(); + }); + })); + }); + + describe('processRefreshToken', () => { + it('calls all methods correctly', waitForAsync(() => { + const refreshSessionWithRefreshTokensSpy = spyOn( + refreshSessionCallbackHandlerService, + 'refreshSessionWithRefreshTokens' + ).and.returnValue(of({} as CallbackContext)); + const refreshTokensRequestTokensSpy = spyOn( + refreshTokenCallbackHandlerService, + 'refreshTokensRequestTokens' + ).and.returnValue(of({} as CallbackContext)); + const callbackHistoryAndResetJwtKeysSpy = spyOn( + historyJwtKeysCallbackHandlerService, + 'callbackHistoryAndResetJwtKeys' + ).and.returnValue(of({} as CallbackContext)); + const callbackStateValidationSpy = spyOn( + stateValidationCallbackHandlerService, + 'callbackStateValidation' + ).and.returnValue(of({} as CallbackContext)); + const callbackUserSpy = spyOn( + userCallbackHandlerService, + 'callbackUser' + ).and.returnValue(of({} as CallbackContext)); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service + .processRefreshToken(allConfigs[0], allConfigs) + .subscribe((value) => { + expect(value).toEqual({} as CallbackContext); + expect(refreshSessionWithRefreshTokensSpy).toHaveBeenCalled(); + expect(refreshTokensRequestTokensSpy).toHaveBeenCalled(); + expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled(); + expect(callbackStateValidationSpy).toHaveBeenCalled(); + expect(callbackUserSpy).toHaveBeenCalled(); + }); + })); + }); +}); diff --git a/src/flows/flows.service.ts b/src/flows/flows.service.ts new file mode 100644 index 0000000..c06024d --- /dev/null +++ b/src/flows/flows.service.ts @@ -0,0 +1,182 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from './callback-context'; +import { CodeFlowCallbackHandlerService } from './callback-handling/code-flow-callback-handler.service'; +import { HistoryJwtKeysCallbackHandlerService } from './callback-handling/history-jwt-keys-callback-handler.service'; +import { ImplicitFlowCallbackHandlerService } from './callback-handling/implicit-flow-callback-handler.service'; +import { RefreshSessionCallbackHandlerService } from './callback-handling/refresh-session-callback-handler.service'; +import { RefreshTokenCallbackHandlerService } from './callback-handling/refresh-token-callback-handler.service'; +import { StateValidationCallbackHandlerService } from './callback-handling/state-validation-callback-handler.service'; +import { UserCallbackHandlerService } from './callback-handling/user-callback-handler.service'; + +@Injectable() +export class FlowsService { + private readonly codeFlowCallbackHandlerService = inject( + CodeFlowCallbackHandlerService + ); + + private readonly implicitFlowCallbackHandlerService = inject( + ImplicitFlowCallbackHandlerService + ); + + private readonly historyJwtKeysCallbackHandlerService = inject( + HistoryJwtKeysCallbackHandlerService + ); + + private readonly userHandlerService = inject(UserCallbackHandlerService); + + private readonly stateValidationCallbackHandlerService = inject( + StateValidationCallbackHandlerService + ); + + private readonly refreshSessionCallbackHandlerService = inject( + RefreshSessionCallbackHandlerService + ); + + private readonly refreshTokenCallbackHandlerService = inject( + RefreshTokenCallbackHandlerService + ); + + processCodeFlowCallback( + urlToCheck: string, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + return this.codeFlowCallbackHandlerService + .codeFlowCallback(urlToCheck, config) + .pipe( + concatMap((callbackContext) => + this.codeFlowCallbackHandlerService.codeFlowCodeRequest( + callbackContext, + config + ) + ), + concatMap((callbackContext) => + this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.stateValidationCallbackHandlerService.callbackStateValidation( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.userHandlerService.callbackUser( + callbackContext, + config, + allConfigs + ) + ) + ); + } + + processSilentRenewCodeFlowCallback( + firstContext: CallbackContext, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + return this.codeFlowCallbackHandlerService + .codeFlowCodeRequest(firstContext, config) + .pipe( + concatMap((callbackContext) => + this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.stateValidationCallbackHandlerService.callbackStateValidation( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.userHandlerService.callbackUser( + callbackContext, + config, + allConfigs + ) + ) + ); + } + + processImplicitFlowCallback( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + hash?: string + ): Observable { + return this.implicitFlowCallbackHandlerService + .implicitFlowCallback(config, allConfigs, hash) + .pipe( + concatMap((callbackContext) => + this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.stateValidationCallbackHandlerService.callbackStateValidation( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.userHandlerService.callbackUser( + callbackContext, + config, + allConfigs + ) + ) + ); + } + + processRefreshToken( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + customParamsRefresh?: { [key: string]: string | number | boolean } + ): Observable { + return this.refreshSessionCallbackHandlerService + .refreshSessionWithRefreshTokens(config) + .pipe( + concatMap((callbackContext) => + this.refreshTokenCallbackHandlerService.refreshTokensRequestTokens( + callbackContext, + config, + customParamsRefresh + ) + ), + concatMap((callbackContext) => + this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.stateValidationCallbackHandlerService.callbackStateValidation( + callbackContext, + config, + allConfigs + ) + ), + concatMap((callbackContext) => + this.userHandlerService.callbackUser( + callbackContext, + config, + allConfigs + ) + ) + ); + } +} diff --git a/src/flows/random/random.service.spec.ts b/src/flows/random/random.service.spec.ts new file mode 100644 index 0000000..44f6aa6 --- /dev/null +++ b/src/flows/random/random.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { LoggerService } from '../../logging/logger.service'; +import { CryptoService } from '../../utils/crypto/crypto.service'; +import { RandomService } from './random.service'; + +describe('RandomService Tests', () => { + let randomService: RandomService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [RandomService, mockProvider(LoggerService), CryptoService], + }); + }); + + beforeEach(() => { + randomService = TestBed.inject(RandomService); + }); + + it('should create', () => { + expect(randomService).toBeTruthy(); + }); + + it('should be not equal', () => { + const r1 = randomService.createRandom(45, { configId: 'configId1' }); + const r2 = randomService.createRandom(45, { configId: 'configId1' }); + + expect(r1).not.toEqual(r2); + }); + + it('correct length with high number', () => { + const r1 = randomService.createRandom(79, { configId: 'configId1' }); + const result = r1.length; + + expect(result).toBe(79); + }); + + it('correct length with small number', () => { + const r1 = randomService.createRandom(7, { configId: 'configId1' }); + const result = r1.length; + + expect(result).toBe(7); + }); + + it('correct length with 0', () => { + const r1 = randomService.createRandom(0, { configId: 'configId1' }); + const result = r1.length; + + expect(result).toBe(0); + expect(r1).toBe(''); + }); + + for (let index = 1; index < 7; index++) { + it('Giving back 10 or more characters when called with numbers less than 7', () => { + const requiredLengthSmallerThenSeven = index; + const fallbackLength = 10; + const r1 = randomService.createRandom(requiredLengthSmallerThenSeven, { + configId: 'configId1', + }); + + expect(r1.length).toBeGreaterThanOrEqual(fallbackLength); + }); + } +}); diff --git a/src/flows/random/random.service.ts b/src/flows/random/random.service.ts new file mode 100644 index 0000000..37a3cf2 --- /dev/null +++ b/src/flows/random/random.service.ts @@ -0,0 +1,60 @@ +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { CryptoService } from '../../utils/crypto/crypto.service'; + +@Injectable() +export class RandomService { + private readonly loggerService = inject(LoggerService); + + private readonly cryptoService = inject(CryptoService); + + createRandom( + requiredLength: number, + configuration: OpenIdConfiguration + ): string { + if (requiredLength <= 0) { + return ''; + } + + if (requiredLength > 0 && requiredLength < 7) { + this.loggerService.logWarning( + configuration, + `RandomService called with ${requiredLength} but 7 chars is the minimum, returning 10 chars` + ); + requiredLength = 10; + } + + const length = requiredLength - 6; + const arr = new Uint8Array(Math.floor(length / 2)); + const crypto = this.cryptoService.getCrypto(); + + if (crypto) { + crypto.getRandomValues(arr); + } + + return Array.from(arr, this.toHex).join('') + this.randomString(7); + } + + private toHex(dec: number): string { + return ('0' + dec.toString(16)).substr(-2); + } + + private randomString(length: number): string { + let result = ''; + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + const values = new Uint32Array(length); + const crypto = this.cryptoService.getCrypto(); + + if (crypto) { + crypto.getRandomValues(values); + for (let i = 0; i < length; i++) { + result += characters[values[i] % characters.length]; + } + } + + return result; + } +} diff --git a/src/flows/reset-auth-data.service.spec.ts b/src/flows/reset-auth-data.service.spec.ts new file mode 100644 index 0000000..b19b2cf --- /dev/null +++ b/src/flows/reset-auth-data.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { LoggerService } from '../logging/logger.service'; +import { UserService } from '../user-data/user.service'; +import { FlowsDataService } from './flows-data.service'; +import { ResetAuthDataService } from './reset-auth-data.service'; + +describe('ResetAuthDataService', () => { + let service: ResetAuthDataService; + let userService: UserService; + let flowsDataService: FlowsDataService; + let authStateService: AuthStateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ResetAuthDataService, + mockProvider(AuthStateService), + mockProvider(FlowsDataService), + mockProvider(UserService), + mockProvider(LoggerService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(ResetAuthDataService); + userService = TestBed.inject(UserService); + flowsDataService = TestBed.inject(FlowsDataService); + authStateService = TestBed.inject(AuthStateService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('resetAuthorizationData', () => { + it('calls resetUserDataInStore when autoUserInfo is true', () => { + const resetUserDataInStoreSpy = spyOn( + userService, + 'resetUserDataInStore' + ); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service.resetAuthorizationData(allConfigs[0], allConfigs); + expect(resetUserDataInStoreSpy).toHaveBeenCalled(); + }); + + it('calls correct methods', () => { + const resetStorageFlowDataSpy = spyOn( + flowsDataService, + 'resetStorageFlowData' + ); + const setUnauthorizedAndFireEventSpy = spyOn( + authStateService, + 'setUnauthenticatedAndFireEvent' + ); + const allConfigs = [ + { + configId: 'configId1', + }, + ]; + + service.resetAuthorizationData(allConfigs[0], allConfigs); + + expect(resetStorageFlowDataSpy).toHaveBeenCalled(); + expect(setUnauthorizedAndFireEventSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/flows/reset-auth-data.service.ts b/src/flows/reset-auth-data.service.ts new file mode 100644 index 0000000..1b327c5 --- /dev/null +++ b/src/flows/reset-auth-data.service.ts @@ -0,0 +1,38 @@ +import { inject, Injectable } from 'injection-js'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { UserService } from '../user-data/user.service'; +import { FlowsDataService } from './flows-data.service'; + +@Injectable() +export class ResetAuthDataService { + private readonly loggerService = inject(LoggerService); + + private readonly userService = inject(UserService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly authStateService = inject(AuthStateService); + + resetAuthorizationData( + currentConfiguration: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[] + ): void { + if (!currentConfiguration) { + return; + } + + this.userService.resetUserDataInStore(currentConfiguration, allConfigs); + this.flowsDataService.resetStorageFlowData(currentConfiguration); + this.authStateService.setUnauthenticatedAndFireEvent( + currentConfiguration, + allConfigs + ); + + this.loggerService.logDebug( + currentConfiguration, + 'Local Login information cleaned up and event fired' + ); + } +} diff --git a/src/flows/signin-key-data.service.spec.ts b/src/flows/signin-key-data.service.spec.ts new file mode 100644 index 0000000..08fb0f4 --- /dev/null +++ b/src/flows/signin-key-data.service.spec.ts @@ -0,0 +1,220 @@ +import { HttpResponse } from '@angular/common/http'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { isObservable, of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { createRetriableStream } from '../../test/create-retriable-stream.helper'; +import { DataService } from '../api/data.service'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { SigninKeyDataService } from './signin-key-data.service'; + +const DUMMY_JWKS = { + keys: [ + { + kid: 'random-id', + kty: 'RSA', + alg: 'RS256', + use: 'sig', + n: 'some-value', + e: 'AQAB', + x5c: ['some-value'], + x5t: 'some-value', + 'x5t#S256': 'some-value', + }, + ], +}; + +describe('Signin Key Data Service', () => { + let service: SigninKeyDataService; + let storagePersistenceService: StoragePersistenceService; + let dataService: DataService; + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SigninKeyDataService, + mockProvider(DataService), + mockProvider(LoggerService), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(SigninKeyDataService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + dataService = TestBed.inject(DataService); + loggerService = TestBed.inject(LoggerService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getSigningKeys', () => { + it('throws error when no wellKnownEndpoints given', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue(null); + const result = service.getSigningKeys({ configId: 'configId1' }); + + result.subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('throws error when no jwksUri given', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ jwksUri: null }); + const result = service.getSigningKeys({ configId: 'configId1' }); + + result.subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('calls dataservice if jwksurl is given', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ jwksUri: 'someUrl' }); + const spy = spyOn(dataService, 'get').and.callFake(() => of()); + + const result = service.getSigningKeys({ configId: 'configId1' }); + + result.subscribe({ + complete: () => { + expect(spy).toHaveBeenCalledOnceWith('someUrl', { + configId: 'configId1', + }); + }, + }); + })); + + it('should retry once', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ jwksUri: 'someUrl' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + of(DUMMY_JWKS) + ) + ); + + service.getSigningKeys({ configId: 'configId1' }).subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_JWKS); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ jwksUri: 'someUrl' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of(DUMMY_JWKS) + ) + ); + + service.getSigningKeys({ configId: 'configId1' }).subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_JWKS); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ jwksUri: 'someUrl' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of(DUMMY_JWKS) + ) + ); + + service.getSigningKeys({ configId: 'configId1' }).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + }); + + describe('handleErrorGetSigningKeys', () => { + it('keeps observable if error is catched', waitForAsync(() => { + const result = (service as any).handleErrorGetSigningKeys( + new HttpResponse() + ); + const hasTypeObservable = isObservable(result); + + expect(hasTypeObservable).toBeTrue(); + })); + + it('logs error if error is response', waitForAsync(() => { + const logSpy = spyOn(loggerService, 'logError'); + + (service as any) + .handleErrorGetSigningKeys( + new HttpResponse({ status: 400, statusText: 'nono' }), + { configId: 'configId1' } + ) + .subscribe({ + error: () => { + expect(logSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + '400 - nono {}' + ); + }, + }); + })); + + it('logs error if error is not a response', waitForAsync(() => { + const logSpy = spyOn(loggerService, 'logError'); + + (service as any) + .handleErrorGetSigningKeys('Just some Error', { configId: 'configId1' }) + .subscribe({ + error: () => { + expect(logSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'Just some Error' + ); + }, + }); + })); + + it('logs error if error with message property is not a response', waitForAsync(() => { + const logSpy = spyOn(loggerService, 'logError'); + + (service as any) + .handleErrorGetSigningKeys( + { message: 'Just some Error' }, + { configId: 'configId1' } + ) + .subscribe({ + error: () => { + expect(logSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'Just some Error' + ); + }, + }); + })); + }); +}); diff --git a/src/flows/signin-key-data.service.ts b/src/flows/signin-key-data.service.ts new file mode 100644 index 0000000..58f287f --- /dev/null +++ b/src/flows/signin-key-data.service.ts @@ -0,0 +1,71 @@ +import { HttpResponse } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, throwError } from 'rxjs'; +import { catchError, retry } from 'rxjs/operators'; +import { DataService } from '../api/data.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { JwtKeys } from '../validation/jwtkeys'; + +@Injectable() +export class SigninKeyDataService { + private readonly loggerService = inject(LoggerService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly dataService = inject(DataService); + + getSigningKeys( + currentConfiguration: OpenIdConfiguration + ): Observable { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + currentConfiguration + ); + const jwksUri = authWellKnownEndPoints?.jwksUri; + + if (!jwksUri) { + const error = `getSigningKeys: authWellKnownEndpoints.jwksUri is: '${jwksUri}'`; + + this.loggerService.logWarning(currentConfiguration, error); + + return throwError(() => new Error(error)); + } + + this.loggerService.logDebug( + currentConfiguration, + 'Getting signinkeys from ', + jwksUri + ); + + return this.dataService.get(jwksUri, currentConfiguration).pipe( + retry(2), + catchError((e) => this.handleErrorGetSigningKeys(e, currentConfiguration)) + ); + } + + private handleErrorGetSigningKeys( + errorResponse: HttpResponse | any, + currentConfiguration: OpenIdConfiguration + ): Observable { + let errMsg = ''; + + if (errorResponse instanceof HttpResponse) { + const body = errorResponse.body || {}; + const err = JSON.stringify(body); + const { status, statusText } = errorResponse; + + errMsg = `${status || ''} - ${statusText || ''} ${err || ''}`; + } else { + const { message } = errorResponse; + + errMsg = !!message ? message : `${errorResponse}`; + } + this.loggerService.logError(currentConfiguration, errMsg); + + return throwError(() => new Error(errMsg)); + } +} diff --git a/src/iframe/check-session.service.spec.ts b/src/iframe/check-session.service.spec.ts new file mode 100644 index 0000000..db4db75 --- /dev/null +++ b/src/iframe/check-session.service.spec.ts @@ -0,0 +1,356 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { skip } from 'rxjs/operators'; +import { mockAbstractProvider, mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { OidcSecurityService } from '../oidc.security.service'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { AbstractSecurityStorage } from '../storage/abstract-security-storage'; +import { DefaultSessionStorageService } from '../storage/default-sessionstorage.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { PlatformProvider } from '../utils/platform-provider/platform.provider'; +import { CheckSessionService } from './check-session.service'; +import { IFrameService } from './existing-iframe.service'; + +describe('CheckSessionService', () => { + let checkSessionService: CheckSessionService; + let loggerService: LoggerService; + let iFrameService: IFrameService; + let storagePersistenceService: StoragePersistenceService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CheckSessionService, + OidcSecurityService, + IFrameService, + PublicEventsService, + mockProvider(StoragePersistenceService), + mockProvider(LoggerService), + mockProvider(PlatformProvider), + mockAbstractProvider( + AbstractSecurityStorage, + DefaultSessionStorageService + ), + ], + }); + }); + + beforeEach(() => { + checkSessionService = TestBed.inject(CheckSessionService); + loggerService = TestBed.inject(LoggerService); + iFrameService = TestBed.inject(IFrameService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + }); + + afterEach(() => { + const iFrameIdwhichshouldneverexist = window.document.getElementById( + 'idwhichshouldneverexist' + ); + + if (iFrameIdwhichshouldneverexist) { + iFrameIdwhichshouldneverexist.parentNode?.removeChild( + iFrameIdwhichshouldneverexist + ); + } + const myiFrameForCheckSession = window.document.getElementById( + 'myiFrameForCheckSession' + ); + + if (myiFrameForCheckSession) { + myiFrameForCheckSession.parentNode?.removeChild(myiFrameForCheckSession); + } + }); + + it('should create', () => { + expect(checkSessionService).toBeTruthy(); + }); + + it('getOrCreateIframe calls iFrameService.addIFrameToWindowBody if no Iframe exists', () => { + spyOn(iFrameService, 'addIFrameToWindowBody').and.callThrough(); + + const result = (checkSessionService as any).getOrCreateIframe({ + configId: 'configId1', + }); + + expect(result).toBeTruthy(); + expect(iFrameService.addIFrameToWindowBody).toHaveBeenCalled(); + }); + + it('getOrCreateIframe returns true if document found on window.document', () => { + iFrameService.addIFrameToWindowBody('myiFrameForCheckSession', { + configId: 'configId1', + }); + + const result = (checkSessionService as any).getOrCreateIframe(); + + expect(result).toBeDefined(); + }); + + it('init appends iframe on body with correct values', () => { + expect((checkSessionService as any).sessionIframe).toBeFalsy(); + spyOn(loggerService, 'logDebug').and.callFake(() => undefined); + + (checkSessionService as any).init(); + const iframe = (checkSessionService as any).getOrCreateIframe({ + configId: 'configId1', + }); + + expect(iframe).toBeTruthy(); + expect(iframe.id).toBe('myiFrameForCheckSession'); + expect(iframe.style.display).toBe('none'); + const iFrame = document.getElementById('myiFrameForCheckSession'); + + expect(iFrame).toBeDefined(); + }); + + it('log warning if authWellKnownEndpoints.check_session_iframe is not existing', () => { + const spyLogWarning = spyOn(loggerService, 'logWarning'); + const config = { configId: 'configId1' }; + + spyOn(loggerService, 'logDebug').and.callFake(() => undefined); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ checkSessionIframe: undefined }); + (checkSessionService as any).init(config); + + expect(spyLogWarning).toHaveBeenCalledOnceWith(config, jasmine.any(String)); + }); + + it('start() calls pollserversession() with clientId if no scheduledheartbeat is set', () => { + const spy = spyOn(checkSessionService, 'pollServerSession'); + const config = { clientId: 'clientId', configId: 'configId1' }; + + checkSessionService.start(config); + expect(spy).toHaveBeenCalledOnceWith('clientId', config); + }); + + it('start() does not call pollServerSession() if scheduledHeartBeatRunning is set', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(checkSessionService, 'pollServerSession'); + + (checkSessionService as any).scheduledHeartBeatRunning = (): void => + undefined; + checkSessionService.start(config); + expect(spy).not.toHaveBeenCalled(); + }); + + it('stopCheckingSession sets heartbeat to null', () => { + (checkSessionService as any).scheduledHeartBeatRunning = setTimeout( + () => undefined, + 999 + ); + checkSessionService.stop(); + const heartBeat = (checkSessionService as any).scheduledHeartBeatRunning; + + expect(heartBeat).toBeNull(); + }); + + it('stopCheckingSession does nothing if scheduledHeartBeatRunning is not set', () => { + (checkSessionService as any).scheduledHeartBeatRunning = null; + const spy = spyOn(checkSessionService, 'clearScheduledHeartBeat'); + + checkSessionService.stop(); + expect(spy).not.toHaveBeenCalledOnceWith(); + }); + + describe('serverStateChanged', () => { + it('returns false if startCheckSession is not configured', () => { + const config = { startCheckSession: false, configId: 'configId1' }; + const result = checkSessionService.serverStateChanged(config); + + expect(result).toBeFalsy(); + }); + + it('returns false if checkSessionReceived is false', () => { + (checkSessionService as any).checkSessionReceived = false; + const config = { startCheckSession: true, configId: 'configId1' }; + const result = checkSessionService.serverStateChanged(config); + + expect(result).toBeFalse(); + }); + + it('returns true if startCheckSession is configured and checkSessionReceived is true', () => { + (checkSessionService as any).checkSessionReceived = true; + const config = { startCheckSession: true, configId: 'configId1' }; + const result = checkSessionService.serverStateChanged(config); + + expect(result).toBeTrue(); + }); + }); + + describe('pollServerSession', () => { + beforeEach(() => { + spyOn(checkSessionService, 'init').and.returnValue(of(undefined)); + }); + + it('increases outstandingMessages', () => { + spyOn(checkSessionService, 'getExistingIframe').and.returnValue({ + contentWindow: { postMessage: () => undefined }, + }); + const authWellKnownEndpoints = { + checkSessionIframe: 'https://some-testing-url.com', + }; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints) + .withArgs('session_state', config) + .and.returnValue('session_state'); + spyOn(loggerService, 'logDebug').and.callFake(() => undefined); + (checkSessionService as any).pollServerSession('clientId', config); + expect((checkSessionService as any).outstandingMessages).toBe(1); + }); + + it('logs warning if iframe does not exist', () => { + spyOn(checkSessionService, 'getExistingIframe').and.returnValue( + null + ); + const authWellKnownEndpoints = { + checkSessionIframe: 'https://some-testing-url.com', + }; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + const spyLogWarning = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + spyOn(loggerService, 'logDebug').and.callFake(() => undefined); + (checkSessionService as any).pollServerSession('clientId', config); + expect(spyLogWarning).toHaveBeenCalledOnceWith( + config, + jasmine.any(String) + ); + }); + + it('logs warning if clientId is not set', () => { + spyOn(checkSessionService, 'getExistingIframe').and.returnValue({}); + const authWellKnownEndpoints = { + checkSessionIframe: 'https://some-testing-url.com', + }; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + const spyLogWarning = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + spyOn(loggerService, 'logDebug').and.callFake(() => undefined); + (checkSessionService as any).pollServerSession('', config); + expect(spyLogWarning).toHaveBeenCalledOnceWith( + config, + jasmine.any(String) + ); + }); + + it('logs debug if session_state is not set', () => { + spyOn(checkSessionService, 'getExistingIframe').and.returnValue({}); + const authWellKnownEndpoints = { + checkSessionIframe: 'https://some-testing-url.com', + }; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints) + .withArgs('session_state', config) + .and.returnValue(null); + + const spyLogDebug = spyOn(loggerService, 'logDebug').and.callFake( + () => undefined + ); + + (checkSessionService as any).pollServerSession('clientId', config); + expect(spyLogDebug).toHaveBeenCalledTimes(2); + }); + + it('logs debug if session_state is set but authWellKnownEndpoints are not set', () => { + spyOn(checkSessionService, 'getExistingIframe').and.returnValue({}); + const authWellKnownEndpoints = null; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints) + .withArgs('session_state', config) + .and.returnValue('some_session_state'); + const spyLogDebug = spyOn(loggerService, 'logDebug').and.callFake( + () => undefined + ); + + (checkSessionService as any).pollServerSession('clientId', config); + expect(spyLogDebug).toHaveBeenCalledTimes(2); + }); + }); + + describe('init', () => { + it('returns falsy observable when lastIframerefresh and iframeRefreshInterval are bigger than now', waitForAsync(() => { + const serviceAsAny = checkSessionService as any; + const dateNow = new Date(); + const lastRefresh = dateNow.setMinutes(dateNow.getMinutes() + 30); + + serviceAsAny.lastIFrameRefresh = lastRefresh; + serviceAsAny.iframeRefreshInterval = lastRefresh; + + serviceAsAny.init().subscribe((result: any) => { + expect(result).toBeUndefined(); + }); + })); + }); + + describe('isCheckSessionConfigured', () => { + it('returns true if startCheckSession on config is true', () => { + const config = { configId: 'configId1', startCheckSession: true }; + + const result = checkSessionService.isCheckSessionConfigured(config); + + expect(result).toBe(true); + }); + + it('returns true if startCheckSession on config is true', () => { + const config = { configId: 'configId1', startCheckSession: false }; + + const result = checkSessionService.isCheckSessionConfigured(config); + + expect(result).toBe(false); + }); + }); + + describe('checkSessionChanged$', () => { + it('emits when internal event is thrown', waitForAsync(() => { + checkSessionService.checkSessionChanged$ + .pipe(skip(1)) + .subscribe((result) => { + expect(result).toBe(true); + }); + + const serviceAsAny = checkSessionService as any; + + serviceAsAny.checkSessionChangedInternal$.next(true); + })); + + it('emits false initially', waitForAsync(() => { + checkSessionService.checkSessionChanged$.subscribe((result) => { + expect(result).toBe(false); + }); + })); + + it('emits false then true when emitted', waitForAsync(() => { + const expectedResultsInOrder = [false, true]; + let counter = 0; + + checkSessionService.checkSessionChanged$.subscribe((result) => { + expect(result).toBe(expectedResultsInOrder[counter]); + counter++; + }); + + (checkSessionService as any).checkSessionChangedInternal$.next(true); + })); + }); +}); diff --git a/src/iframe/check-session.service.ts b/src/iframe/check-session.service.ts new file mode 100644 index 0000000..ace87d8 --- /dev/null +++ b/src/iframe/check-session.service.ts @@ -0,0 +1,319 @@ +import { DOCUMENT } from '../dom'; +import { Injectable, NgZone, OnDestroy, inject } from 'injection-js'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { IFrameService } from './existing-iframe.service'; + +const IFRAME_FOR_CHECK_SESSION_IDENTIFIER = 'myiFrameForCheckSession'; + +// http://openid.net/specs/openid-connect-session-1_0-ID4.html + +@Injectable() +export class CheckSessionService implements OnDestroy { + private readonly loggerService = inject(LoggerService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly iFrameService = inject(IFrameService); + + private readonly eventService = inject(PublicEventsService); + + private readonly zone = inject(NgZone); + + private readonly document = inject(DOCUMENT); + + private checkSessionReceived = false; + + private scheduledHeartBeatRunning: number | null = null; + + private lastIFrameRefresh = 0; + + private outstandingMessages = 0; + + private readonly heartBeatInterval = 3000; + + private readonly iframeRefreshInterval = 60000; + + private readonly checkSessionChangedInternal$ = new BehaviorSubject( + false + ); + + private iframeMessageEventListener?: ( + this: Window, + ev: MessageEvent + ) => any; + + get checkSessionChanged$(): Observable { + return this.checkSessionChangedInternal$.asObservable(); + } + + ngOnDestroy(): void { + this.stop(); + const windowAsDefaultView = this.document.defaultView; + + if (windowAsDefaultView && this.iframeMessageEventListener) { + windowAsDefaultView.removeEventListener( + 'message', + this.iframeMessageEventListener, + false + ); + } + } + + isCheckSessionConfigured(configuration: OpenIdConfiguration): boolean { + const { startCheckSession } = configuration; + + return Boolean(startCheckSession); + } + + start(configuration: OpenIdConfiguration): void { + if (!!this.scheduledHeartBeatRunning) { + return; + } + + const { clientId } = configuration; + + this.pollServerSession(clientId, configuration); + } + + stop(): void { + if (!this.scheduledHeartBeatRunning) { + return; + } + + this.clearScheduledHeartBeat(); + this.checkSessionReceived = false; + } + + serverStateChanged(configuration: OpenIdConfiguration): boolean { + const { startCheckSession } = configuration; + + return Boolean(startCheckSession) && this.checkSessionReceived; + } + + getExistingIframe(): HTMLIFrameElement | null { + return this.iFrameService.getExistingIFrame( + IFRAME_FOR_CHECK_SESSION_IDENTIFIER + ); + } + + private init(configuration: OpenIdConfiguration): Observable { + if (this.lastIFrameRefresh + this.iframeRefreshInterval > Date.now()) { + return of(); + } + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (!authWellKnownEndPoints) { + this.loggerService.logWarning( + configuration, + 'CheckSession - init check session: authWellKnownEndpoints is undefined. Returning.' + ); + + return of(); + } + + const existingIframe = this.getOrCreateIframe(configuration); + + // https://www.w3.org/TR/2000/REC-DOM-Level-2-Events-20001113/events.html#Events-EventTarget-addEventListener + // If multiple identical EventListeners are registered on the same EventTarget with the same parameters the duplicate instances are discarded. They do not cause the EventListener to be called twice and since they are discarded they do not need to be removed with the removeEventListener method. + // this is done even if iframe exists for HMR to work, since iframe exists on service init + this.bindMessageEventToIframe(configuration); + const checkSessionIframe = authWellKnownEndPoints.checkSessionIframe; + const contentWindow = existingIframe.contentWindow; + + if (!checkSessionIframe) { + this.loggerService.logWarning( + configuration, + 'CheckSession - init check session: checkSessionIframe is not configured to run' + ); + + return of(); + } + + if (!contentWindow) { + this.loggerService.logWarning( + configuration, + 'CheckSession - init check session: IFrame contentWindow does not exist' + ); + } else { + contentWindow.location.replace(checkSessionIframe); + } + + return new Observable((observer) => { + existingIframe.onload = (): void => { + this.lastIFrameRefresh = Date.now(); + observer.next(); + observer.complete(); + }; + }); + } + + private pollServerSession( + clientId: string | undefined, + configuration: OpenIdConfiguration + ): void { + this.outstandingMessages = 0; + + const pollServerSessionRecur = (): void => { + this.init(configuration) + .pipe(take(1)) + .subscribe(() => { + const existingIframe = this.getExistingIframe(); + + if (existingIframe && clientId) { + this.loggerService.logDebug( + configuration, + `CheckSession - clientId : '${clientId}' - existingIframe: '${existingIframe}'` + ); + const sessionState = this.storagePersistenceService.read( + 'session_state', + configuration + ); + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + const contentWindow = existingIframe.contentWindow; + + if ( + sessionState && + authWellKnownEndPoints?.checkSessionIframe && + contentWindow + ) { + const iframeOrigin = new URL( + authWellKnownEndPoints.checkSessionIframe + )?.origin; + + this.outstandingMessages++; + contentWindow.postMessage( + clientId + ' ' + sessionState, + iframeOrigin + ); + } else { + this.loggerService.logDebug( + configuration, + `CheckSession - session_state is '${sessionState}' - AuthWellKnownEndPoints is '${JSON.stringify( + authWellKnownEndPoints, + null, + 2 + )}'` + ); + this.checkSessionChangedInternal$.next(true); + } + } else { + this.loggerService.logWarning( + configuration, + `CheckSession - OidcSecurityCheckSession pollServerSession checkSession IFrame does not exist: + clientId : '${clientId}' - existingIframe: '${existingIframe}'` + ); + } + + // after sending three messages with no response, fail. + if (this.outstandingMessages > 3) { + this.loggerService.logError( + configuration, + `CheckSession - OidcSecurityCheckSession not receiving check session response messages. + Outstanding messages: '${this.outstandingMessages}'. Server unreachable?` + ); + } + + this.zone.runOutsideAngular(() => { + this.scheduledHeartBeatRunning = + this.document?.defaultView?.setTimeout( + () => this.zone.run(pollServerSessionRecur), + this.heartBeatInterval + ) ?? null; + }); + }); + }; + + pollServerSessionRecur(); + } + + private clearScheduledHeartBeat(): void { + if (this.scheduledHeartBeatRunning !== null) { + clearTimeout(this.scheduledHeartBeatRunning); + this.scheduledHeartBeatRunning = null; + } + } + + private messageHandler(configuration: OpenIdConfiguration, e: any): void { + const existingIFrame = this.getExistingIframe(); + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + const startsWith = !!authWellKnownEndPoints?.checkSessionIframe?.startsWith( + e.origin + ); + + this.outstandingMessages = 0; + + if ( + existingIFrame && + startsWith && + e.source === existingIFrame.contentWindow + ) { + if (e.data === 'error') { + this.loggerService.logWarning( + configuration, + 'CheckSession - error from check session messageHandler' + ); + } else if (e.data === 'changed') { + this.loggerService.logDebug( + configuration, + `CheckSession - ${e} from check session messageHandler` + ); + this.checkSessionReceived = true; + this.eventService.fireEvent(EventTypes.CheckSessionReceived, e.data); + this.checkSessionChangedInternal$.next(true); + } else { + this.eventService.fireEvent(EventTypes.CheckSessionReceived, e.data); + this.loggerService.logDebug( + configuration, + `CheckSession - ${e.data} from check session messageHandler` + ); + } + } + } + + private bindMessageEventToIframe(configuration: OpenIdConfiguration): void { + this.iframeMessageEventListener = this.messageHandler.bind( + this, + configuration + ); + + const defaultView = this.document.defaultView; + + if (defaultView) { + defaultView.addEventListener( + 'message', + this.iframeMessageEventListener, + false + ); + } + } + + private getOrCreateIframe( + configuration: OpenIdConfiguration + ): HTMLIFrameElement { + return ( + this.getExistingIframe() || + this.iFrameService.addIFrameToWindowBody( + IFRAME_FOR_CHECK_SESSION_IDENTIFIER, + configuration + ) + ); + } +} diff --git a/src/iframe/existing-iframe.service.ts b/src/iframe/existing-iframe.service.ts new file mode 100644 index 0000000..2731213 --- /dev/null +++ b/src/iframe/existing-iframe.service.ts @@ -0,0 +1,75 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; + +@Injectable() +export class IFrameService { + private readonly document = inject(DOCUMENT); + + private readonly loggerService = inject(LoggerService); + + getExistingIFrame(identifier: string): HTMLIFrameElement | null { + const iFrameOnParent = this.getIFrameFromParentWindow(identifier); + + if (this.isIFrameElement(iFrameOnParent)) { + return iFrameOnParent; + } + + const iFrameOnSelf = this.getIFrameFromWindow(identifier); + + if (this.isIFrameElement(iFrameOnSelf)) { + return iFrameOnSelf; + } + + return null; + } + + addIFrameToWindowBody( + identifier: string, + config: OpenIdConfiguration + ): HTMLIFrameElement { + const sessionIframe = this.document.createElement('iframe'); + + sessionIframe.id = identifier; + sessionIframe.title = identifier; + this.loggerService.logDebug(config, sessionIframe); + sessionIframe.style.display = 'none'; + this.document.body.appendChild(sessionIframe); + + return sessionIframe; + } + + private getIFrameFromParentWindow( + identifier: string + ): HTMLIFrameElement | null { + try { + const iFrameElement = + this.document.defaultView?.parent.document.getElementById(identifier); + + if (this.isIFrameElement(iFrameElement)) { + return iFrameElement; + } + + return null; + } catch (e) { + return null; + } + } + + private getIFrameFromWindow(identifier: string): HTMLIFrameElement | null { + const iFrameElement = this.document.getElementById(identifier); + + if (this.isIFrameElement(iFrameElement)) { + return iFrameElement; + } + + return null; + } + + private isIFrameElement( + element: HTMLElement | null | undefined + ): element is HTMLIFrameElement { + return !!element && element instanceof HTMLIFrameElement; + } +} diff --git a/src/iframe/refresh-session-iframe.service.spec.ts b/src/iframe/refresh-session-iframe.service.spec.ts new file mode 100644 index 0000000..1dd2fca --- /dev/null +++ b/src/iframe/refresh-session-iframe.service.spec.ts @@ -0,0 +1,67 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { UrlService } from '../utils/url/url.service'; +import { RefreshSessionIframeService } from './refresh-session-iframe.service'; +import { SilentRenewService } from './silent-renew.service'; + +describe('RefreshSessionIframeService ', () => { + let refreshSessionIframeService: RefreshSessionIframeService; + let urlService: UrlService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RefreshSessionIframeService, + mockProvider(SilentRenewService), + mockProvider(LoggerService), + mockProvider(UrlService), + ], + }); + }); + + beforeEach(() => { + refreshSessionIframeService = TestBed.inject(RefreshSessionIframeService); + urlService = TestBed.inject(UrlService); + }); + + it('should create', () => { + expect(refreshSessionIframeService).toBeTruthy(); + }); + + describe('refreshSessionWithIframe', () => { + it('calls sendAuthorizeRequestUsingSilentRenew with created url', waitForAsync(() => { + spyOn(urlService, 'getRefreshSessionSilentRenewUrl').and.returnValue( + of('a-url') + ); + const sendAuthorizeRequestUsingSilentRenewSpy = spyOn( + refreshSessionIframeService as any, + 'sendAuthorizeRequestUsingSilentRenew' + ).and.returnValue(of(null)); + const allConfigs = [{ configId: 'configId1' }]; + + refreshSessionIframeService + .refreshSessionWithIframe(allConfigs[0], allConfigs) + .subscribe(() => { + expect( + sendAuthorizeRequestUsingSilentRenewSpy + ).toHaveBeenCalledOnceWith('a-url', allConfigs[0], allConfigs); + }); + })); + }); + + describe('initSilentRenewRequest', () => { + it('dispatches customevent to window object', waitForAsync(() => { + const dispatchEventSpy = spyOn(window, 'dispatchEvent'); + + (refreshSessionIframeService as any).initSilentRenewRequest(); + + expect(dispatchEventSpy).toHaveBeenCalledOnceWith( + new CustomEvent('oidc-silent-renew-init', { + detail: jasmine.any(Number), + }) + ); + })); + }); +}); diff --git a/src/iframe/refresh-session-iframe.service.ts b/src/iframe/refresh-session-iframe.service.ts new file mode 100644 index 0000000..db9ed49 --- /dev/null +++ b/src/iframe/refresh-session-iframe.service.ts @@ -0,0 +1,106 @@ +import { DOCUMENT } from '../dom'; +import { Injectable, RendererFactory2, inject } from 'injection-js'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { UrlService } from '../utils/url/url.service'; +import { SilentRenewService } from './silent-renew.service'; + +@Injectable() +export class RefreshSessionIframeService { + private readonly renderer = inject(RendererFactory2).createRenderer( + null, + null + ); + + private readonly loggerService = inject(LoggerService); + + private readonly urlService = inject(UrlService); + + private readonly silentRenewService = inject(SilentRenewService); + + private readonly document = inject(DOCUMENT); + + refreshSessionWithIframe( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + customParams?: { [key: string]: string | number | boolean } + ): Observable { + this.loggerService.logDebug( + config, + 'BEGIN refresh session Authorize Iframe renew' + ); + + return this.urlService + .getRefreshSessionSilentRenewUrl(config, customParams) + .pipe( + switchMap((url) => { + return this.sendAuthorizeRequestUsingSilentRenew( + url, + config, + allConfigs + ); + }) + ); + } + + private sendAuthorizeRequestUsingSilentRenew( + url: string | null, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const sessionIframe = this.silentRenewService.getOrCreateIframe(config); + + this.initSilentRenewRequest(config, allConfigs); + this.loggerService.logDebug( + config, + `sendAuthorizeRequestUsingSilentRenew for URL: ${url}` + ); + + return new Observable((observer) => { + const onLoadHandler = (): void => { + sessionIframe.removeEventListener('load', onLoadHandler); + this.loggerService.logDebug( + config, + 'removed event listener from IFrame' + ); + observer.next(true); + observer.complete(); + }; + + sessionIframe.addEventListener('load', onLoadHandler); + sessionIframe.contentWindow?.location.replace(url ?? ''); + }); + } + + private initSilentRenewRequest( + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + const instanceId = Math.random(); + + const initDestroyHandler = this.renderer.listen( + 'window', + 'oidc-silent-renew-init', + (e: CustomEvent) => { + if (e.detail !== instanceId) { + initDestroyHandler(); + renewDestroyHandler(); + } + } + ); + const renewDestroyHandler = this.renderer.listen( + 'window', + 'oidc-silent-renew-message', + (e) => + this.silentRenewService.silentRenewEventHandler(e, config, allConfigs) + ); + + this.document.defaultView?.dispatchEvent( + new CustomEvent('oidc-silent-renew-init', { + detail: instanceId, + }) + ); + } +} diff --git a/src/iframe/silent-renew.service.spec.ts b/src/iframe/silent-renew.service.spec.ts new file mode 100644 index 0000000..da0d92f --- /dev/null +++ b/src/iframe/silent-renew.service.spec.ts @@ -0,0 +1,377 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Observable, of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ImplicitFlowCallbackService } from '../callback/implicit-flow-callback.service'; +import { IntervalService } from '../callback/interval.service'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { LoggerService } from '../logging/logger.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { ValidationResult } from '../validation/validation-result'; +import { IFrameService } from './existing-iframe.service'; +import { SilentRenewService } from './silent-renew.service'; + +describe('SilentRenewService ', () => { + let silentRenewService: SilentRenewService; + let flowHelper: FlowHelper; + let implicitFlowCallbackService: ImplicitFlowCallbackService; + let iFrameService: IFrameService; + let flowsDataService: FlowsDataService; + let loggerService: LoggerService; + let flowsService: FlowsService; + let authStateService: AuthStateService; + let resetAuthDataService: ResetAuthDataService; + let intervalService: IntervalService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SilentRenewService, + IFrameService, + mockProvider(FlowsService), + mockProvider(ResetAuthDataService), + mockProvider(FlowsDataService), + mockProvider(AuthStateService), + mockProvider(LoggerService), + mockProvider(ImplicitFlowCallbackService), + mockProvider(IntervalService), + FlowHelper, + ], + }); + }); + + beforeEach(() => { + silentRenewService = TestBed.inject(SilentRenewService); + iFrameService = TestBed.inject(IFrameService); + flowHelper = TestBed.inject(FlowHelper); + implicitFlowCallbackService = TestBed.inject(ImplicitFlowCallbackService); + flowsDataService = TestBed.inject(FlowsDataService); + flowsService = TestBed.inject(FlowsService); + loggerService = TestBed.inject(LoggerService); + authStateService = TestBed.inject(AuthStateService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + intervalService = TestBed.inject(IntervalService); + }); + + it('should create', () => { + expect(silentRenewService).toBeTruthy(); + }); + + describe('refreshSessionWithIFrameCompleted', () => { + it('is of type observable', () => { + expect(silentRenewService.refreshSessionWithIFrameCompleted$).toEqual( + jasmine.any(Observable) + ); + }); + }); + + describe('isSilentRenewConfigured', () => { + it('returns true if refreshToken is configured false and silentRenew is configured true', () => { + const config = { useRefreshToken: false, silentRenew: true }; + const result = silentRenewService.isSilentRenewConfigured(config); + + expect(result).toBe(true); + }); + + it('returns false if refreshToken is configured true and silentRenew is configured true', () => { + const config = { useRefreshToken: true, silentRenew: true }; + + const result = silentRenewService.isSilentRenewConfigured(config); + + expect(result).toBe(false); + }); + + it('returns false if refreshToken is configured false and silentRenew is configured false', () => { + const config = { useRefreshToken: false, silentRenew: false }; + + const result = silentRenewService.isSilentRenewConfigured(config); + + expect(result).toBe(false); + }); + }); + + describe('getOrCreateIframe', () => { + it('returns iframe if iframe is truthy', () => { + spyOn(silentRenewService as any, 'getExistingIframe').and.returnValue({ + name: 'anything', + }); + + const result = silentRenewService.getOrCreateIframe({ + configId: 'configId1', + }); + + expect(result).toEqual({ name: 'anything' } as HTMLIFrameElement); + }); + + it('adds iframe to body if existing iframe is falsy', () => { + const config = { configId: 'configId1' }; + + spyOn(silentRenewService as any, 'getExistingIframe').and.returnValue( + null + ); + + const spy = spyOn(iFrameService, 'addIFrameToWindowBody').and.returnValue( + { name: 'anything' } as HTMLIFrameElement + ); + + const result = silentRenewService.getOrCreateIframe(config); + + expect(result).toEqual({ name: 'anything' } as HTMLIFrameElement); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledOnceWith('myiFrameForSilentRenew', config); + }); + }); + + describe('codeFlowCallbackSilentRenewIframe', () => { + it('calls processSilentRenewCodeFlowCallback with correct arguments', waitForAsync(() => { + const config = { configId: 'configId1' }; + const allConfigs = [config]; + + const spy = spyOn( + flowsService, + 'processSilentRenewCodeFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const expectedContext = { + code: 'some-code', + refreshToken: '', + state: 'some-state', + sessionState: 'some-session-state', + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + } as CallbackContext; + const url = 'url-part-1'; + const urlParts = + 'code=some-code&state=some-state&session_state=some-session-state'; + + silentRenewService + .codeFlowCallbackSilentRenewIframe([url, urlParts], config, allConfigs) + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + expectedContext, + config, + allConfigs + ); + }); + })); + + it('throws error if url has error param and resets everything on error', waitForAsync(() => { + const config = { configId: 'configId1' }; + const allConfigs = [config]; + + const spy = spyOn( + flowsService, + 'processSilentRenewCodeFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const authStateServiceSpy = spyOn( + authStateService, + 'updateAndPublishAuthState' + ); + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const setNonceSpy = spyOn(flowsDataService, 'setNonce'); + const stopPeriodicTokenCheckSpy = spyOn( + intervalService, + 'stopPeriodicTokenCheck' + ); + + const url = 'url-part-1'; + const urlParts = 'error=some_error'; + + silentRenewService + .codeFlowCallbackSilentRenewIframe([url, urlParts], config, allConfigs) + .subscribe({ + error: (error) => { + expect(error).toEqual(new Error('some_error')); + expect(spy).not.toHaveBeenCalled(); + expect(authStateServiceSpy).toHaveBeenCalledOnceWith({ + isAuthenticated: false, + validationResult: ValidationResult.LoginRequired, + isRenewProcess: true, + }); + expect(resetAuthorizationDataSpy).toHaveBeenCalledOnceWith( + config, + allConfigs + ); + expect(setNonceSpy).toHaveBeenCalledOnceWith('', config); + expect(stopPeriodicTokenCheckSpy).toHaveBeenCalledTimes(1); + }, + }); + })); + }); + + describe('silentRenewEventHandler', () => { + it('returns if no details is given', fakeAsync(() => { + const isCurrentFlowCodeFlowSpy = spyOn( + flowHelper, + 'isCurrentFlowCodeFlow' + ).and.returnValue(false); + + spyOn( + implicitFlowCallbackService, + 'authenticatedImplicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const eventData = { detail: null } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + expect(isCurrentFlowCodeFlowSpy).not.toHaveBeenCalled(); + })); + + it('calls authorizedImplicitFlowCallback if current flow is not code flow', fakeAsync(() => { + const isCurrentFlowCodeFlowSpy = spyOn( + flowHelper, + 'isCurrentFlowCodeFlow' + ).and.returnValue(false); + const authorizedImplicitFlowCallbackSpy = spyOn( + implicitFlowCallbackService, + 'authenticatedImplicitFlowCallback' + ).and.returnValue(of({} as CallbackContext)); + const eventData = { detail: 'detail' } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + expect(isCurrentFlowCodeFlowSpy).toHaveBeenCalled(); + expect(authorizedImplicitFlowCallbackSpy).toHaveBeenCalledOnceWith( + allConfigs[0], + allConfigs, + 'detail' + ); + })); + + it('calls codeFlowCallbackSilentRenewIframe if current flow is code flow', fakeAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const codeFlowCallbackSilentRenewIframe = spyOn( + silentRenewService, + 'codeFlowCallbackSilentRenewIframe' + ).and.returnValue(of({} as CallbackContext)); + const eventData = { detail: 'detail?detail2' } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + expect(codeFlowCallbackSilentRenewIframe).toHaveBeenCalledOnceWith( + ['detail', 'detail2'], + allConfigs[0], + allConfigs + ); + })); + + it('calls authorizedImplicitFlowCallback if current flow is not code flow', fakeAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const codeFlowCallbackSilentRenewIframe = spyOn( + silentRenewService, + 'codeFlowCallbackSilentRenewIframe' + ).and.returnValue(of({} as CallbackContext)); + const eventData = { detail: 'detail?detail2' } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + expect(codeFlowCallbackSilentRenewIframe).toHaveBeenCalledOnceWith( + ['detail', 'detail2'], + allConfigs[0], + allConfigs + ); + })); + + it('calls next on refreshSessionWithIFrameCompleted with callbackcontext', fakeAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + spyOn( + silentRenewService, + 'codeFlowCallbackSilentRenewIframe' + ).and.returnValue( + of({ refreshToken: 'callbackContext' } as CallbackContext) + ); + const eventData = { detail: 'detail?detail2' } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.refreshSessionWithIFrameCompleted$.subscribe( + (result) => { + expect(result).toEqual({ + refreshToken: 'callbackContext', + } as CallbackContext); + } + ); + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + })); + + it('loggs and calls flowsDataService.resetSilentRenewRunning in case of an error', fakeAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + spyOn( + silentRenewService, + 'codeFlowCallbackSilentRenewIframe' + ).and.returnValue(throwError(() => new Error('ERROR'))); + const resetSilentRenewRunningSpy = spyOn( + flowsDataService, + 'resetSilentRenewRunning' + ); + const logErrorSpy = spyOn(loggerService, 'logError'); + const allConfigs = [{ configId: 'configId1' }]; + const eventData = { detail: 'detail?detail2' } as CustomEvent; + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + expect(resetSilentRenewRunningSpy).toHaveBeenCalledTimes(1); + expect(logErrorSpy).toHaveBeenCalledTimes(1); + })); + + it('calls next on refreshSessionWithIFrameCompleted with null in case of error', fakeAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + spyOn( + silentRenewService, + 'codeFlowCallbackSilentRenewIframe' + ).and.returnValue(throwError(() => new Error('ERROR'))); + const eventData = { detail: 'detail?detail2' } as CustomEvent; + const allConfigs = [{ configId: 'configId1' }]; + + silentRenewService.refreshSessionWithIFrameCompleted$.subscribe( + (result) => { + expect(result).toBeNull(); + } + ); + + silentRenewService.silentRenewEventHandler( + eventData, + allConfigs[0], + allConfigs + ); + tick(1000); + })); + }); +}); diff --git a/src/iframe/silent-renew.service.ts b/src/iframe/silent-renew.service.ts new file mode 100644 index 0000000..25c62de --- /dev/null +++ b/src/iframe/silent-renew.service.ts @@ -0,0 +1,168 @@ +import { HttpParams } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, Subject, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ImplicitFlowCallbackService } from '../callback/implicit-flow-callback.service'; +import { IntervalService } from '../callback/interval.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { FlowsDataService } from '../flows/flows-data.service'; +import { FlowsService } from '../flows/flows.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { LoggerService } from '../logging/logger.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { ValidationResult } from '../validation/validation-result'; +import { IFrameService } from './existing-iframe.service'; + +const IFRAME_FOR_SILENT_RENEW_IDENTIFIER = 'myiFrameForSilentRenew'; + +@Injectable() +export class SilentRenewService { + private readonly refreshSessionWithIFrameCompletedInternal$ = + new Subject(); + + get refreshSessionWithIFrameCompleted$(): Observable { + return this.refreshSessionWithIFrameCompletedInternal$.asObservable(); + } + + private readonly loggerService = inject(LoggerService); + + private readonly iFrameService = inject(IFrameService); + + private readonly flowsService = inject(FlowsService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly authStateService = inject(AuthStateService); + + private readonly flowHelper = inject(FlowHelper); + + private readonly implicitFlowCallbackService = inject( + ImplicitFlowCallbackService + ); + + private readonly intervalService = inject(IntervalService); + + getOrCreateIframe(config: OpenIdConfiguration): HTMLIFrameElement { + const existingIframe = this.getExistingIframe(); + + if (!existingIframe) { + return this.iFrameService.addIFrameToWindowBody( + IFRAME_FOR_SILENT_RENEW_IDENTIFIER, + config + ); + } + + return existingIframe; + } + + isSilentRenewConfigured(configuration: OpenIdConfiguration): boolean { + const { useRefreshToken, silentRenew } = configuration; + + return !useRefreshToken && Boolean(silentRenew); + } + + codeFlowCallbackSilentRenewIframe( + urlParts: string[], + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const params = new HttpParams({ + fromString: urlParts[1], + }); + + const errorParam = params.get('error'); + + if (errorParam) { + this.authStateService.updateAndPublishAuthState({ + isAuthenticated: false, + validationResult: ValidationResult.LoginRequired, + isRenewProcess: true, + }); + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + this.flowsDataService.setNonce('', config); + this.intervalService.stopPeriodicTokenCheck(); + + return throwError(() => new Error(errorParam)); + } + + const code = params.get('code') ?? ''; + const state = params.get('state') ?? ''; + const sessionState = params.get('session_state'); + + const callbackContext: CallbackContext = { + code, + refreshToken: '', + state, + sessionState, + authResult: null, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + return this.flowsService + .processSilentRenewCodeFlowCallback(callbackContext, config, allConfigs) + .pipe( + catchError((error) => { + this.intervalService.stopPeriodicTokenCheck(); + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + + return throwError(() => new Error(error)); + }) + ); + } + + silentRenewEventHandler( + e: CustomEvent, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + this.loggerService.logDebug(config, 'silentRenewEventHandler'); + if (!e.detail) { + return; + } + + let callback$: Observable; + const isCodeFlow = this.flowHelper.isCurrentFlowCodeFlow(config); + + if (isCodeFlow) { + const urlParts = e.detail.toString().split('?'); + + callback$ = this.codeFlowCallbackSilentRenewIframe( + urlParts, + config, + allConfigs + ); + } else { + callback$ = + this.implicitFlowCallbackService.authenticatedImplicitFlowCallback( + config, + allConfigs, + e.detail + ); + } + + callback$.subscribe({ + next: (callbackContext) => { + this.refreshSessionWithIFrameCompletedInternal$.next(callbackContext); + this.flowsDataService.resetSilentRenewRunning(config); + }, + error: (err: unknown) => { + this.loggerService.logError(config, 'Error: ' + err); + this.refreshSessionWithIFrameCompletedInternal$.next(null); + this.flowsDataService.resetSilentRenewRunning(config); + }, + }); + } + + private getExistingIframe(): HTMLIFrameElement | null { + return this.iFrameService.getExistingIFrame( + IFRAME_FOR_SILENT_RENEW_IDENTIFIER + ); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9f7afcf --- /dev/null +++ b/src/index.ts @@ -0,0 +1,31 @@ +// Public classes. + +export { PassedInitialConfig } from './auth-config'; +export * from './auth-options'; +export * from './auth-state/auth-result'; +export * from './auth-state/auth-state'; +export * from './auth.module'; +export * from './auto-login/auto-login-all-routes.guard'; +export * from './auto-login/auto-login-partial-routes.guard'; +export * from './config/auth-well-known/auth-well-known-endpoints'; +export * from './config/config.service'; +export * from './config/loader/config-loader'; +export * from './config/openid-configuration'; +export * from './interceptor/auth.interceptor'; +export * from './logging/abstract-logger.service'; +export * from './logging/log-level'; +export * from './login/login-response'; +export * from './login/popup/popup-options'; +export * from './login/popup/popup.service'; +export * from './oidc.security.service'; +export * from './provide-auth'; +export * from './public-events/event-types'; +export * from './public-events/notification'; +export * from './public-events/public-events.service'; +export * from './storage/abstract-security-storage'; +export * from './storage/default-localstorage.service'; +export * from './storage/default-sessionstorage.service'; +export * from './user-data/userdata-result'; +export * from './validation/jwtkeys'; +export * from './validation/state-validation-result'; +export * from './validation/validation-result'; diff --git a/src/interceptor/auth.interceptor.spec.ts b/src/interceptor/auth.interceptor.spec.ts new file mode 100644 index 0000000..d363f2f --- /dev/null +++ b/src/interceptor/auth.interceptor.spec.ts @@ -0,0 +1,289 @@ +import { + HTTP_INTERCEPTORS, + HttpClient, + provideHttpClient, + withInterceptors, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoggerService } from '../logging/logger.service'; +import { AuthInterceptor, authInterceptor } from './auth.interceptor'; +import { ClosestMatchingRouteService } from './closest-matching-route.service'; + +describe(`AuthHttpInterceptor`, () => { + let httpTestingController: HttpTestingController; + let configurationService: ConfigurationService; + let httpClient: HttpClient; + let authStateService: AuthStateService; + let closestMatchingRouteService: ClosestMatchingRouteService; + + describe(`with Class Interceptor`, () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + ClosestMatchingRouteService, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true, + }, + mockProvider(AuthStateService), + mockProvider(LoggerService), + mockProvider(ConfigurationService), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + httpClient = TestBed.inject(HttpClient); + httpTestingController = TestBed.inject(HttpTestingController); + configurationService = TestBed.inject(ConfigurationService); + authStateService = TestBed.inject(AuthStateService); + closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + runTests(); + }); + + describe(`with Functional Interceptor`, () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ClosestMatchingRouteService, + provideHttpClient(withInterceptors([authInterceptor()])), + provideHttpClientTesting(), + mockProvider(AuthStateService), + mockProvider(LoggerService), + mockProvider(ConfigurationService), + ], + }); + + httpClient = TestBed.inject(HttpClient); + httpTestingController = TestBed.inject(HttpTestingController); + configurationService = TestBed.inject(ConfigurationService); + authStateService = TestBed.inject(AuthStateService); + closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + runTests(); + }); + + function runTests(): void { + it('should add an Authorization header when route matches and token is present', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + secureRoutes: [actionUrl], + configId: 'configId1', + }, + ]); + + spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken'); + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(true); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when `secureRoutes` is not given', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + configId: 'configId1', + }, + ]); + spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken'); + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when no routes configured', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + secureRoutes: [], + configId: 'configId1', + }, + ]); + + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken'); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when no routes configured', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + secureRoutes: [], + configId: 'configId1', + }, + ]); + + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when route is configured but no token is present', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + secureRoutes: [actionUrl], + configId: 'configId1', + }, + ]); + + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + spyOn(authStateService, 'getAccessToken').and.returnValue(''); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when no config is present', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(false); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should not add an Authorization header when no configured route is matching the request', waitForAsync(() => { + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + const actionUrl = `https://jsonplaceholder.typicode.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { + secureRoutes: [actionUrl], + configId: 'configId1', + }, + ]); + spyOn( + closestMatchingRouteService, + 'getConfigIdForClosestMatchingRoute' + ).and.returnValue({ + matchingRoute: null, + matchingConfig: null, + }); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(false); + + httpRequest.flush('something'); + httpTestingController.verify(); + })); + + it('should add an Authorization header when multiple routes are configured and token is present', waitForAsync(() => { + const actionUrl = `https://jsonplaceholder.typicode.com/`; + const actionUrl2 = `https://some-other-url.com/`; + + spyOn(configurationService, 'getAllConfigurations').and.returnValue([ + { secureRoutes: [actionUrl, actionUrl2], configId: 'configId1' }, + ]); + + spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken'); + spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true); + + httpClient.get(actionUrl).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + httpClient.get(actionUrl2).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpTestingController.expectOne(actionUrl); + + expect(httpRequest.request.headers.has('Authorization')).toEqual(true); + + const httpRequest2 = httpTestingController.expectOne(actionUrl2); + + expect(httpRequest2.request.headers.has('Authorization')).toEqual(true); + + httpRequest.flush('something'); + httpRequest2.flush('something'); + httpTestingController.verify(); + })); + } +}); diff --git a/src/interceptor/auth.interceptor.ts b/src/interceptor/auth.interceptor.ts new file mode 100644 index 0000000..b9afedc --- /dev/null +++ b/src/interceptor/auth.interceptor.ts @@ -0,0 +1,121 @@ +import { + HttpEvent, + HttpHandler, + HttpHandlerFn, + HttpInterceptor, + HttpInterceptorFn, + HttpRequest, +} from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable } from 'rxjs'; +import { AuthStateService } from '../auth-state/auth-state.service'; +import { ConfigurationService } from '../config/config.service'; +import { LoggerService } from '../logging/logger.service'; +import { flattenArray } from '../utils/collections/collections.helper'; +import { ClosestMatchingRouteService } from './closest-matching-route.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private readonly authStateService = inject(AuthStateService); + + private readonly configurationService = inject(ConfigurationService); + + private readonly loggerService = inject(LoggerService); + + private readonly closestMatchingRouteService = inject( + ClosestMatchingRouteService + ); + + intercept( + req: HttpRequest, + next: HttpHandler + ): Observable> { + return interceptRequest(req, next.handle, { + configurationService: this.configurationService, + authStateService: this.authStateService, + closestMatchingRouteService: this.closestMatchingRouteService, + loggerService: this.loggerService, + }); + } +} + +export function authInterceptor(): HttpInterceptorFn { + return (req, next) => { + return interceptRequest(req, next, { + configurationService: inject(ConfigurationService), + authStateService: inject(AuthStateService), + closestMatchingRouteService: inject(ClosestMatchingRouteService), + loggerService: inject(LoggerService), + }); + }; +} + +function interceptRequest( + req: HttpRequest, + next: HttpHandlerFn, + deps: { + authStateService: AuthStateService; + configurationService: ConfigurationService; + loggerService: LoggerService; + closestMatchingRouteService: ClosestMatchingRouteService; + } +): Observable> { + if (!deps.configurationService.hasAtLeastOneConfig()) { + return next(req); + } + + const allConfigurations = deps.configurationService.getAllConfigurations(); + const allRoutesConfigured = allConfigurations.map( + (x) => x.secureRoutes || [] + ); + const allRoutesConfiguredFlat = flattenArray(allRoutesConfigured); + + if (allRoutesConfiguredFlat.length === 0) { + deps.loggerService.logDebug( + allConfigurations[0], + `No routes to check configured` + ); + + return next(req); + } + + const { matchingConfig, matchingRoute } = + deps.closestMatchingRouteService.getConfigIdForClosestMatchingRoute( + req.url, + allConfigurations + ); + + if (!matchingConfig) { + deps.loggerService.logDebug( + allConfigurations[0], + `Did not find any configured route for route ${req.url}` + ); + + return next(req); + } + + deps.loggerService.logDebug( + matchingConfig, + `'${req.url}' matches configured route '${matchingRoute}'` + ); + const token = deps.authStateService.getAccessToken(matchingConfig); + + if (!token) { + deps.loggerService.logDebug( + matchingConfig, + `Wanted to add token to ${req.url} but found no token: '${token}'` + ); + + return next(req); + } + + deps.loggerService.logDebug( + matchingConfig, + `'${req.url}' matches configured route '${matchingRoute}', adding token` + ); + req = req.clone({ + headers: req.headers.set('Authorization', 'Bearer ' + token), + }); + + return next(req); +} diff --git a/src/interceptor/closest-matching-route.service.spec.ts b/src/interceptor/closest-matching-route.service.spec.ts new file mode 100644 index 0000000..7cf5581 --- /dev/null +++ b/src/interceptor/closest-matching-route.service.spec.ts @@ -0,0 +1,180 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { ClosestMatchingRouteService } from './closest-matching-route.service'; + +describe('ClosestMatchingRouteService', () => { + let service: ClosestMatchingRouteService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ClosestMatchingRouteService, mockProvider(LoggerService)], + }); + }); + + beforeEach(() => { + service = TestBed.inject(ClosestMatchingRouteService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getConfigForClosestMatchingRoute', () => { + it('gets best match for configured routes', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://my-secure-url.com/', + 'https://my-second-secure-url.com/', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://my-third-secure-url.com/', + 'https://my-fourth-second-secure-url.com/', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'https://my-secure-url.com/', + allConfigs + ); + + expect(matchingConfig).toEqual(allConfigs[0]); + }); + + it('gets best match for configured routes - same route prefix', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://my-secure-url.com/', + 'https://my-secure-url.com/test', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://my-third-secure-url.com/', + 'https://my-fourth-second-secure-url.com/', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'https://my-secure-url.com/', + allConfigs + ); + + expect(matchingConfig).toEqual(allConfigs[0]); + }); + + it('gets best match for configured routes - main route', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://first-route.com/', + 'https://second-route.com/test', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://third-route.com/test2', + 'https://fourth-route.com/test3', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'https://first-route.com/', + allConfigs + ); + + expect(matchingConfig).toEqual(allConfigs[0]); + }); + + it('gets best match for configured routes - request route with params', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://first-route.com/', + 'https://second-route.com/test', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://third-route.com/test2', + 'https://fourth-route.com/test3', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'https://first-route.com/anyparam', + allConfigs + ); + + expect(matchingConfig).toEqual(allConfigs[0]); + }); + + it('gets best match for configured routes - configured route with params', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://first-route.com/', + 'https://second-route.com/test', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://third-route.com/test2', + 'https://fourth-route.com/test3', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'https://third-route.com/', + allConfigs + ); + + expect(matchingConfig).toBeNull(); + }); + + it('gets best match for configured routes - no config Id', () => { + const allConfigs = [ + { + configId: 'configId1', + secureRoutes: [ + 'https://my-secure-url.com/', + 'https://my-secure-url.com/test', + ], + }, + { + configId: 'configId2', + secureRoutes: [ + 'https://my-secure-url.com/test2', + 'https://my-secure-url.com/test2/test', + ], + }, + ]; + + const { matchingConfig } = service.getConfigIdForClosestMatchingRoute( + 'blabla', + allConfigs + ); + + expect(matchingConfig).toBeNull(); + }); + }); +}); diff --git a/src/interceptor/closest-matching-route.service.ts b/src/interceptor/closest-matching-route.service.ts new file mode 100644 index 0000000..ddbe51c --- /dev/null +++ b/src/interceptor/closest-matching-route.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../config/openid-configuration'; + +@Injectable() +export class ClosestMatchingRouteService { + getConfigIdForClosestMatchingRoute( + route: string, + configurations: OpenIdConfiguration[] + ): ClosestMatchingRouteResult { + for (const config of configurations) { + const { secureRoutes } = config; + + for (const configuredRoute of secureRoutes ?? []) { + if (route.startsWith(configuredRoute)) { + return { + matchingRoute: configuredRoute, + matchingConfig: config, + }; + } + } + } + + return { + matchingRoute: null, + matchingConfig: null, + }; + } +} + +export interface ClosestMatchingRouteResult { + matchingRoute: string | null; + matchingConfig: OpenIdConfiguration | null; +} diff --git a/src/logging/abstract-logger.service.ts b/src/logging/abstract-logger.service.ts new file mode 100644 index 0000000..c414649 --- /dev/null +++ b/src/logging/abstract-logger.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from 'injection-js'; + +/** + * Implement this class-interface to create a custom logger service. + */ +@Injectable() +export abstract class AbstractLoggerService { + abstract logError(message: string | object, ...args: any[]): void; + + abstract logWarning(message: string | object, ...args: any[]): void; + + abstract logDebug(message: string | object, ...args: any[]): void; +} diff --git a/src/logging/console-logger.service.ts b/src/logging/console-logger.service.ts new file mode 100644 index 0000000..2aa9df6 --- /dev/null +++ b/src/logging/console-logger.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from 'injection-js'; +import { AbstractLoggerService } from './abstract-logger.service'; + +@Injectable() +export class ConsoleLoggerService implements AbstractLoggerService { + logError(message: string | object, ...args: any[]): void { + console.error(message, ...args); + } + + logWarning(message: string | object, ...args: any[]): void { + console.warn(message, ...args); + } + + logDebug(message: string | object, ...args: any[]): void { + console.debug(message, ...args); + } +} diff --git a/src/logging/log-level.ts b/src/logging/log-level.ts new file mode 100644 index 0000000..3e4e117 --- /dev/null +++ b/src/logging/log-level.ts @@ -0,0 +1,6 @@ +export enum LogLevel { + None, + Debug, + Warn, + Error, +} diff --git a/src/logging/logger.service.spec.ts b/src/logging/logger.service.spec.ts new file mode 100644 index 0000000..7da12b9 --- /dev/null +++ b/src/logging/logger.service.spec.ts @@ -0,0 +1,252 @@ +import { TestBed } from '@angular/core/testing'; +import { AbstractLoggerService } from './abstract-logger.service'; +import { ConsoleLoggerService } from './console-logger.service'; +import { LogLevel } from './log-level'; +import { LoggerService } from './logger.service'; + +describe('Logger Service', () => { + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + LoggerService, + { provide: AbstractLoggerService, useClass: ConsoleLoggerService }, + ], + }); + }); + + beforeEach(() => { + loggerService = TestBed.inject(LoggerService); + }); + + it('should create', () => { + expect(loggerService).toBeTruthy(); + }); + + describe('logError', () => { + it('should not log error if loglevel is None', () => { + const spy = spyOn(console, 'error'); + + loggerService.logError( + { configId: 'configId1', logLevel: LogLevel.None }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should log error as default if error is string', () => { + const spy = spyOn(console, 'error'); + + loggerService.logError({ configId: 'configId1' }, 'some message'); + expect(spy).toHaveBeenCalledOnceWith('[ERROR] configId1 - some message'); + }); + + it('should log error as default if error is object', () => { + const spy = spyOn(console, 'error'); + + loggerService.logError({ configId: 'configId1' }, { some: 'message' }); + expect(spy).toHaveBeenCalledOnceWith( + '[ERROR] configId1 - {"some":"message"}' + ); + }); + + it('should always log error with args', () => { + const spy = spyOn(console, 'error'); + + loggerService.logError( + { configId: 'configId1' }, + 'some message', + 'arg1', + 'arg2' + ); + expect(spy).toHaveBeenCalledOnceWith( + '[ERROR] configId1 - some message', + 'arg1', + 'arg2' + ); + }); + }); + + describe('logWarn', () => { + it('should not log if no log level is set (null)', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: undefined }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log if no config is given', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning({}, 'some message'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log if no log level is set (undefined)', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning({ configId: 'configId1' }, 'some message'); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log if log level is turned off', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.None }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should log warning when loglevel is Warn and message is string', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.Warn }, + 'some message' + ); + expect(spy).toHaveBeenCalledOnceWith('[WARN] configId1 - some message'); + }); + + it('should log warning when loglevel is Warn and message is object', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.Warn }, + { some: 'message' } + ); + expect(spy).toHaveBeenCalledOnceWith( + '[WARN] configId1 - {"some":"message"}' + ); + }); + + it('should log warning when loglevel is Warn with args', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.Warn }, + 'some message', + 'arg1', + 'arg2' + ); + expect(spy).toHaveBeenCalledOnceWith( + '[WARN] configId1 - some message', + 'arg1', + 'arg2' + ); + }); + + it('should log warning when loglevel is Debug', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.Debug }, + 'some message' + ); + expect(spy).toHaveBeenCalledOnceWith('[WARN] configId1 - some message'); + }); + + it('should not log warning when loglevel is error', () => { + const spy = spyOn(console, 'warn'); + + loggerService.logWarning( + { configId: 'configId1', logLevel: LogLevel.Error }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('logDebug', () => { + it('should not log if no log level is set (null)', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: undefined }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log if no log level is set (undefined)', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug({ configId: 'configId1' }, 'some message'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log if log level is turned off', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.None }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should log when loglevel is Debug and value is string', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.Debug }, + 'some message' + ); + expect(spy).toHaveBeenCalledOnceWith('[DEBUG] configId1 - some message'); + }); + + it('should log when loglevel is Debug and value is object', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.Debug }, + { some: 'message' } + ); + expect(spy).toHaveBeenCalledOnceWith( + '[DEBUG] configId1 - {"some":"message"}' + ); + }); + + it('should log when loglevel is Debug with args', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.Debug }, + 'some message', + 'arg1', + 'arg2' + ); + expect(spy).toHaveBeenCalledOnceWith( + '[DEBUG] configId1 - some message', + 'arg1', + 'arg2' + ); + }); + + it('should not log when loglevel is Warn', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.Warn }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not log when loglevel is error', () => { + const spy = spyOn(console, 'debug'); + + loggerService.logDebug( + { configId: 'configId1', logLevel: LogLevel.Error }, + 'some message' + ); + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/logging/logger.service.ts b/src/logging/logger.service.ts new file mode 100644 index 0000000..b1f29d6 --- /dev/null +++ b/src/logging/logger.service.ts @@ -0,0 +1,144 @@ +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { AbstractLoggerService } from './abstract-logger.service'; +import { LogLevel } from './log-level'; + +@Injectable() +export class LoggerService { + private readonly abstractLoggerService = inject(AbstractLoggerService); + + logError( + configuration: OpenIdConfiguration, + message: string | object, + ...args: any[] + ): void { + if (this.loggingIsTurnedOff(configuration)) { + return; + } + + const { configId } = configuration; + const messageToLog = this.isObject(message) + ? JSON.stringify(message) + : message; + + if (!!args && !!args.length) { + this.abstractLoggerService.logError( + `[ERROR] ${configId} - ${messageToLog}`, + ...args + ); + } else { + this.abstractLoggerService.logError( + `[ERROR] ${configId} - ${messageToLog}` + ); + } + } + + logWarning( + configuration: OpenIdConfiguration, + message: string | object, + ...args: any[] + ): void { + if (!this.logLevelIsSet(configuration)) { + return; + } + + if (this.loggingIsTurnedOff(configuration)) { + return; + } + + if ( + !this.currentLogLevelIsEqualOrSmallerThan(configuration, LogLevel.Warn) + ) { + return; + } + + const { configId } = configuration; + const messageToLog = this.isObject(message) + ? JSON.stringify(message) + : message; + + if (!!args && !!args.length) { + this.abstractLoggerService.logWarning( + `[WARN] ${configId} - ${messageToLog}`, + ...args + ); + } else { + this.abstractLoggerService.logWarning( + `[WARN] ${configId} - ${messageToLog}` + ); + } + } + + logDebug( + configuration: OpenIdConfiguration | null, + message: string | object, + ...args: any[] + ): void { + if (!configuration) { + return; + } + + if (!this.logLevelIsSet(configuration)) { + return; + } + + if (this.loggingIsTurnedOff(configuration)) { + return; + } + + if ( + !this.currentLogLevelIsEqualOrSmallerThan(configuration, LogLevel.Debug) + ) { + return; + } + + const { configId } = configuration; + const messageToLog = this.isObject(message) + ? JSON.stringify(message) + : message; + + if (!!args && !!args.length) { + this.abstractLoggerService.logDebug( + `[DEBUG] ${configId} - ${messageToLog}`, + ...args + ); + } else { + this.abstractLoggerService.logDebug( + `[DEBUG] ${configId} - ${messageToLog}` + ); + } + } + + private currentLogLevelIsEqualOrSmallerThan( + configuration: OpenIdConfiguration, + logLevelToCompare: LogLevel + ): boolean { + const { logLevel } = configuration || {}; + + if (!logLevel) { + return false; + } + + return logLevel <= logLevelToCompare; + } + + private logLevelIsSet(configuration: OpenIdConfiguration): boolean { + const { logLevel } = configuration || {}; + + if (logLevel === null) { + return false; + } + + return logLevel !== undefined; + } + + private loggingIsTurnedOff(configuration: OpenIdConfiguration): boolean { + const { logLevel } = configuration || {}; + + return logLevel === LogLevel.None; + } + + private isObject(possibleObject: any): boolean { + return Object.prototype.toString.call(possibleObject) === '[object Object]'; + } +} diff --git a/src/login/login-response.ts b/src/login/login-response.ts new file mode 100644 index 0000000..09515c1 --- /dev/null +++ b/src/login/login-response.ts @@ -0,0 +1,8 @@ +export interface LoginResponse { + isAuthenticated: boolean; + userData: any; + accessToken: string; + idToken: string; + configId?: string; + errorMessage?: string; +} diff --git a/src/login/login.service.spec.ts b/src/login/login.service.spec.ts new file mode 100644 index 0000000..bf538bd --- /dev/null +++ b/src/login/login.service.spec.ts @@ -0,0 +1,201 @@ +import { CommonModule } from '@angular/common'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { LoginResponse } from './login-response'; +import { LoginService } from './login.service'; +import { ParLoginService } from './par/par-login.service'; +import { PopUpLoginService } from './popup/popup-login.service'; +import { PopUpService } from './popup/popup.service'; +import { StandardLoginService } from './standard/standard-login.service'; + +describe('LoginService', () => { + let service: LoginService; + let parLoginService: ParLoginService; + let popUpLoginService: PopUpLoginService; + let standardLoginService: StandardLoginService; + let storagePersistenceService: StoragePersistenceService; + let popUpService: PopUpService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CommonModule], + providers: [ + LoginService, + mockProvider(ParLoginService), + mockProvider(PopUpLoginService), + mockProvider(StandardLoginService), + mockProvider(StoragePersistenceService), + mockProvider(PopUpService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(LoginService); + parLoginService = TestBed.inject(ParLoginService); + popUpLoginService = TestBed.inject(PopUpLoginService); + standardLoginService = TestBed.inject(StandardLoginService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + popUpService = TestBed.inject(PopUpService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('login', () => { + it('calls parLoginService loginPar if usePushedAuthorisationRequests is true', () => { + const config = { usePushedAuthorisationRequests: true }; + const loginParSpy = spyOn(parLoginService, 'loginPar'); + const standardLoginSpy = spyOn(standardLoginService, 'loginStandard'); + + service.login(config); + + expect(loginParSpy).toHaveBeenCalledTimes(1); + expect(standardLoginSpy).not.toHaveBeenCalled(); + }); + + it('calls standardLoginService loginStandard if usePushedAuthorisationRequests is false', () => { + const config = { usePushedAuthorisationRequests: false }; + const loginParSpy = spyOn(parLoginService, 'loginPar'); + const standardLoginSpy = spyOn(standardLoginService, 'loginStandard'); + + service.login(config); + + expect(loginParSpy).not.toHaveBeenCalled(); + expect(standardLoginSpy).toHaveBeenCalledTimes(1); + }); + + it('stores the customParams to the storage if customParams are given', () => { + // arrange + const config = { usePushedAuthorisationRequests: false }; + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + const authOptions = { customParams: { custom: 'params' } }; + + service.login(config, authOptions); + + expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith( + 'storageCustomParamsAuthRequest', + { custom: 'params' }, + config + ); + }); + + it("should throw error if configuration is null and doesn't call loginPar or loginStandard", () => { + // arrange + const config = null; + const loginParSpy = spyOn(parLoginService, 'loginPar'); + const standardLoginSpy = spyOn(standardLoginService, 'loginStandard'); + const authOptions = { customParams: { custom: 'params' } }; + + // act + const fn = (): void => service.login(config, authOptions); + + // assert + expect(fn).toThrow( + new Error('Please provide a configuration before setting up the module') + ); + expect(loginParSpy).not.toHaveBeenCalled(); + expect(standardLoginSpy).not.toHaveBeenCalled(); + }); + }); + + describe('loginWithPopUp', () => { + it('calls parLoginService loginWithPopUpPar if usePushedAuthorisationRequests is true', waitForAsync(() => { + // arrange + const config = { usePushedAuthorisationRequests: true }; + const loginWithPopUpPar = spyOn( + parLoginService, + 'loginWithPopUpPar' + ).and.returnValue(of({} as LoginResponse)); + const loginWithPopUpStandardSpy = spyOn( + popUpLoginService, + 'loginWithPopUpStandard' + ).and.returnValue(of({} as LoginResponse)); + + // act + service.loginWithPopUp(config, [config]).subscribe(() => { + // assert + expect(loginWithPopUpPar).toHaveBeenCalledTimes(1); + expect(loginWithPopUpStandardSpy).not.toHaveBeenCalled(); + }); + })); + + it('calls standardLoginService loginstandard if usePushedAuthorisationRequests is false', waitForAsync(() => { + // arrange + const config = { usePushedAuthorisationRequests: false }; + const loginWithPopUpPar = spyOn( + parLoginService, + 'loginWithPopUpPar' + ).and.returnValue(of({} as LoginResponse)); + const loginWithPopUpStandardSpy = spyOn( + popUpLoginService, + 'loginWithPopUpStandard' + ).and.returnValue(of({} as LoginResponse)); + + // act + service.loginWithPopUp(config, [config]).subscribe(() => { + // assert + expect(loginWithPopUpPar).not.toHaveBeenCalled(); + expect(loginWithPopUpStandardSpy).toHaveBeenCalledTimes(1); + }); + })); + + it('stores the customParams to the storage if customParams are given', waitForAsync(() => { + // arrange + const config = { usePushedAuthorisationRequests: false }; + const storagePersistenceServiceSpy = spyOn( + storagePersistenceService, + 'write' + ); + const authOptions = { customParams: { custom: 'params' } }; + + spyOn(popUpLoginService, 'loginWithPopUpStandard').and.returnValue( + of({} as LoginResponse) + ); + + // act + service.loginWithPopUp(config, [config], authOptions).subscribe(() => { + // assert + expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith( + 'storageCustomParamsAuthRequest', + { custom: 'params' }, + config + ); + }); + })); + + it('returns error if there is already a popup open', () => { + // arrange + const config = { usePushedAuthorisationRequests: false }; + const authOptions = { customParams: { custom: 'params' } }; + const loginWithPopUpPar = spyOn( + parLoginService, + 'loginWithPopUpPar' + ).and.returnValue(of({} as LoginResponse)); + const loginWithPopUpStandardSpy = spyOn( + popUpLoginService, + 'loginWithPopUpStandard' + ).and.returnValue(of({} as LoginResponse)); + + spyOn(popUpService, 'isCurrentlyInPopup').and.returnValue(true); + + // act + service + .loginWithPopUp(config, [config], authOptions) + .subscribe((result) => { + // assert + expect(result).toEqual({ + errorMessage: 'There is already a popup open.', + } as LoginResponse); + expect(loginWithPopUpPar).not.toHaveBeenCalled(); + expect(loginWithPopUpStandardSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/login/login.service.ts b/src/login/login.service.ts new file mode 100644 index 0000000..d88d102 --- /dev/null +++ b/src/login/login.service.ts @@ -0,0 +1,104 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of } from 'rxjs'; +import { AuthOptions } from '../auth-options'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { LoginResponse } from './login-response'; +import { ParLoginService } from './par/par-login.service'; +import { PopUpLoginService } from './popup/popup-login.service'; +import { PopupOptions } from './popup/popup-options'; +import { PopUpService } from './popup/popup.service'; +import { StandardLoginService } from './standard/standard-login.service'; + +@Injectable() +export class LoginService { + private readonly parLoginService = inject(ParLoginService); + + private readonly popUpLoginService = inject(PopUpLoginService); + + private readonly standardLoginService = inject(StandardLoginService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly popupService = inject(PopUpService); + + login( + configuration: OpenIdConfiguration | null, + authOptions?: AuthOptions + ): void { + if (!configuration) { + throw new Error( + 'Please provide a configuration before setting up the module' + ); + } + + const { usePushedAuthorisationRequests } = configuration; + + if (authOptions?.customParams) { + this.storagePersistenceService.write( + 'storageCustomParamsAuthRequest', + authOptions.customParams, + configuration + ); + } + + if (usePushedAuthorisationRequests) { + return this.parLoginService.loginPar(configuration, authOptions); + } else { + return this.standardLoginService.loginStandard( + configuration, + authOptions + ); + } + } + + loginWithPopUp( + configuration: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[], + authOptions?: AuthOptions, + popupOptions?: PopupOptions + ): Observable { + if (!configuration) { + throw new Error( + 'Please provide a configuration before setting up the module' + ); + } + + const isAlreadyInPopUp = + this.popupService.isCurrentlyInPopup(configuration); + + if (isAlreadyInPopUp) { + return of({ + errorMessage: 'There is already a popup open.', + } as LoginResponse); + } + + const { usePushedAuthorisationRequests } = configuration; + + if (authOptions?.customParams) { + this.storagePersistenceService.write( + 'storageCustomParamsAuthRequest', + authOptions.customParams, + configuration + ); + } + + if (usePushedAuthorisationRequests) { + return this.parLoginService.loginWithPopUpPar( + configuration, + allConfigs, + authOptions, + popupOptions + ); + } + + return this.popUpLoginService.loginWithPopUpStandard( + configuration, + allConfigs, + authOptions, + popupOptions + ); + } +} diff --git a/src/login/par/par-login.service.spec.ts b/src/login/par/par-login.service.spec.ts new file mode 100644 index 0000000..02e0a99 --- /dev/null +++ b/src/login/par/par-login.service.spec.ts @@ -0,0 +1,464 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { CheckAuthService } from '../../auth-state/check-auth.service'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { LoggerService } from '../../logging/logger.service'; +import { RedirectService } from '../../utils/redirect/redirect.service'; +import { UrlService } from '../../utils/url/url.service'; +import { LoginResponse } from '../login-response'; +import { PopupResult } from '../popup/popup-result'; +import { PopUpService } from '../popup/popup.service'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; +import { ParLoginService } from './par-login.service'; +import { ParResponse } from './par-response'; +import { ParService } from './par.service'; + +describe('ParLoginService', () => { + let service: ParLoginService; + let responseTypeValidationService: ResponseTypeValidationService; + let loggerService: LoggerService; + let authWellKnownService: AuthWellKnownService; + let parService: ParService; + let urlService: UrlService; + let redirectService: RedirectService; + let popupService: PopUpService; + let checkAuthService: CheckAuthService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ParLoginService, + mockProvider(LoggerService), + mockProvider(ResponseTypeValidationService), + mockProvider(UrlService), + mockProvider(RedirectService), + mockProvider(AuthWellKnownService), + mockProvider(PopUpService), + mockProvider(CheckAuthService), + mockProvider(ParService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(ParLoginService); + loggerService = TestBed.inject(LoggerService); + responseTypeValidationService = TestBed.inject( + ResponseTypeValidationService + ); + authWellKnownService = TestBed.inject(AuthWellKnownService); + parService = TestBed.inject(ParService); + urlService = TestBed.inject(UrlService); + redirectService = TestBed.inject(RedirectService); + popupService = TestBed.inject(PopUpService); + checkAuthService = TestBed.inject(CheckAuthService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('loginPar', () => { + it('does nothing if it has an invalid response type', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(false); + const loggerSpy = spyOn(loggerService, 'logError'); + + const result = service.loginPar({}); + + expect(result).toBeUndefined(); + expect(loggerSpy).toHaveBeenCalled(); + })); + + it('calls parService.postParRequest without custom params when no custom params are passed', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + const spy = spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + + const result = service.loginPar({ + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalled(); + })); + + it('calls parService.postParRequest with custom params when custom params are passed', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + const spy = spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + + const result = service.loginPar(config, { + customParams: { some: 'thing' }, + }); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalledOnceWith(config, { + customParams: { some: 'thing' }, + }); + })); + + it('returns undefined and logs error when no url could be created', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue(''); + const spy = spyOn(loggerService, 'logError'); + + const result = service.loginPar(config); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + })); + + it('calls redirect service redirectTo when url could be created', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + const authOptions = {}; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue('some-par-url'); + const spy = spyOn(redirectService, 'redirectTo'); + + service.loginPar(config, authOptions); + + expect(spy).toHaveBeenCalledOnceWith('some-par-url'); + })); + + it('calls urlHandler when URL is passed', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue('some-par-url'); + const redirectToSpy = spyOn(redirectService, 'redirectTo'); + const spy = jasmine.createSpy(); + const urlHandler = (url: any): void => { + spy(url); + }; + + service.loginPar(config, { urlHandler }); + + expect(spy).toHaveBeenCalledOnceWith('some-par-url'); + expect(redirectToSpy).not.toHaveBeenCalled(); + })); + }); + + describe('loginWithPopUpPar', () => { + it('does nothing if it has an invalid response type', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(false); + const loggerSpy = spyOn(loggerService, 'logError'); + const config = {}; + const allConfigs = [config]; + + service.loginWithPopUpPar(config, allConfigs).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err.message).toBe('Invalid response type!'); + }, + }); + })); + + it('calls parService.postParRequest without custom params when no custom params are passed', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + const spy = spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + + service.loginWithPopUpPar(config, allConfigs).subscribe({ + error: (err) => { + expect(spy).toHaveBeenCalled(); + expect(err.message).toBe( + "Could not create URL with param requestUri: 'url'" + ); + }, + }); + })); + + it('calls parService.postParRequest with custom params when custom params are passed', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + const spy = spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + + service + .loginWithPopUpPar(config, allConfigs, { + customParams: { some: 'thing' }, + }) + .subscribe({ + error: (err) => { + expect(spy).toHaveBeenCalledOnceWith(config, { + customParams: { some: 'thing' }, + }); + expect(err.message).toBe( + "Could not create URL with param requestUri: 'url'" + ); + }, + }); + })); + + it('returns undefined and logs error when no URL could be created', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue(''); + const spy = spyOn(loggerService, 'logError'); + + service + .loginWithPopUpPar(config, allConfigs, { + customParams: { some: 'thing' }, + }) + .subscribe({ + error: (err) => { + expect(err.message).toBe( + "Could not create URL with param requestUri: 'url'" + ); + expect(spy).toHaveBeenCalledTimes(1); + }, + }); + })); + + it('calls popupService openPopUp when URL could be created', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue('some-par-url'); + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + spyOnProperty(popupService, 'result$').and.returnValue( + of({} as PopupResult) + ); + const spy = spyOn(popupService, 'openPopUp'); + + service.loginWithPopUpPar(config, allConfigs).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-par-url', undefined, config); + }); + })); + + it('returns correct properties if URL is received', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + configId: 'configId1', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue('some-par-url'); + + const checkAuthSpy = spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ + isAuthenticated: true, + configId: 'configId1', + idToken: '', + userData: { any: 'userData' }, + accessToken: 'anyAccessToken', + }) + ); + const popupResult: PopupResult = { + userClosed: false, + receivedUrl: 'someUrl', + }; + + spyOnProperty(popupService, 'result$').and.returnValue(of(popupResult)); + + service.loginWithPopUpPar(config, allConfigs).subscribe((result) => { + expect(checkAuthSpy).toHaveBeenCalledOnceWith( + config, + allConfigs, + 'someUrl' + ); + + expect(result).toEqual({ + isAuthenticated: true, + configId: 'configId1', + idToken: '', + userData: { any: 'userData' }, + accessToken: 'anyAccessToken', + }); + }); + })); + + it('returns correct properties if popup was closed by user', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + configId: 'configId1', + }; + const allConfigs = [config]; + + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + spyOn(parService, 'postParRequest').and.returnValue( + of({ requestUri: 'requestUri' } as ParResponse) + ); + spyOn(urlService, 'getAuthorizeParUrl').and.returnValue('some-par-url'); + + const checkAuthSpy = spyOn(checkAuthService, 'checkAuth'); + const popupResult = { userClosed: true } as PopupResult; + + spyOnProperty(popupService, 'result$').and.returnValue(of(popupResult)); + + service.loginWithPopUpPar(config, allConfigs).subscribe((result) => { + expect(checkAuthSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: 'User closed popup', + configId: 'configId1', + idToken: '', + userData: null, + accessToken: '', + }); + }); + })); + }); +}); diff --git a/src/login/par/par-login.service.ts b/src/login/par/par-login.service.ts new file mode 100644 index 0000000..8d34d9a --- /dev/null +++ b/src/login/par/par-login.service.ts @@ -0,0 +1,172 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { switchMap, take } from 'rxjs/operators'; +import { AuthOptions } from '../../auth-options'; +import { CheckAuthService } from '../../auth-state/check-auth.service'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { RedirectService } from '../../utils/redirect/redirect.service'; +import { UrlService } from '../../utils/url/url.service'; +import { LoginResponse } from '../login-response'; +import { PopupOptions } from '../popup/popup-options'; +import { PopupResult } from '../popup/popup-result'; +import { PopUpService } from '../popup/popup.service'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; +import { ParResponse } from './par-response'; +import { ParService } from './par.service'; + +@Injectable() +export class ParLoginService { + private readonly loggerService = inject(LoggerService); + + private readonly responseTypeValidationService = inject( + ResponseTypeValidationService + ); + + private readonly urlService = inject(UrlService); + + private readonly redirectService = inject(RedirectService); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + private readonly popupService = inject(PopUpService); + + private readonly checkAuthService = inject(CheckAuthService); + + private readonly parService = inject(ParService); + + loginPar( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): void { + if ( + !this.responseTypeValidationService.hasConfigValidResponseType( + configuration + ) + ) { + this.loggerService.logError(configuration, 'Invalid response type!'); + + return; + } + + this.loggerService.logDebug( + configuration, + 'BEGIN Authorize OIDC Flow, no auth data' + ); + + this.authWellKnownService + .queryAndStoreAuthWellKnownEndPoints(configuration) + .pipe( + switchMap(() => + this.parService.postParRequest(configuration, authOptions) + ) + ) + .subscribe((response) => { + this.loggerService.logDebug(configuration, 'par response: ', response); + + const url = this.urlService.getAuthorizeParUrl( + response.requestUri, + configuration + ); + + this.loggerService.logDebug(configuration, 'par request url: ', url); + + if (!url) { + this.loggerService.logError( + configuration, + `Could not create URL with param ${response.requestUri}: '${url}'` + ); + + return; + } + + if (authOptions?.urlHandler) { + authOptions.urlHandler(url); + } else { + this.redirectService.redirectTo(url); + } + }); + } + + loginWithPopUpPar( + configuration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + authOptions?: AuthOptions, + popupOptions?: PopupOptions + ): Observable { + const { configId } = configuration; + + if ( + !this.responseTypeValidationService.hasConfigValidResponseType( + configuration + ) + ) { + const errorMessage = 'Invalid response type!'; + + this.loggerService.logError(configuration, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + + this.loggerService.logDebug( + configuration, + 'BEGIN Authorize OIDC Flow with popup, no auth data' + ); + + return this.authWellKnownService + .queryAndStoreAuthWellKnownEndPoints(configuration) + .pipe( + switchMap(() => + this.parService.postParRequest(configuration, authOptions) + ), + switchMap((response: ParResponse) => { + this.loggerService.logDebug( + configuration, + `par response: ${response}` + ); + + const url = this.urlService.getAuthorizeParUrl( + response.requestUri, + configuration + ); + + this.loggerService.logDebug(configuration, 'par request url: ', url); + + if (!url) { + const errorMessage = `Could not create URL with param ${response.requestUri}: 'url'`; + + this.loggerService.logError(configuration, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + + this.popupService.openPopUp(url, popupOptions, configuration); + + return this.popupService.result$.pipe( + take(1), + switchMap((result: PopupResult) => { + const { userClosed, receivedUrl } = result; + + if (userClosed) { + return of({ + isAuthenticated: false, + errorMessage: 'User closed popup', + userData: null, + idToken: '', + accessToken: '', + configId, + }); + } + + return this.checkAuthService.checkAuth( + configuration, + allConfigs, + receivedUrl + ); + }) + ); + }) + ); + } +} diff --git a/src/login/par/par-response.ts b/src/login/par/par-response.ts new file mode 100644 index 0000000..ac2d1a1 --- /dev/null +++ b/src/login/par/par-response.ts @@ -0,0 +1,4 @@ +export interface ParResponse { + requestUri: string; + expiresIn: number; +} diff --git a/src/login/par/par.service.spec.ts b/src/login/par/par.service.spec.ts new file mode 100644 index 0000000..7a222d7 --- /dev/null +++ b/src/login/par/par.service.spec.ts @@ -0,0 +1,204 @@ +import { HttpHeaders } from '@angular/common/http'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { createRetriableStream } from '../../../test/create-retriable-stream.helper'; +import { DataService } from '../../api/data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { ParService } from './par.service'; + +describe('ParService', () => { + let service: ParService; + let loggerService: LoggerService; + let urlService: UrlService; + let dataService: DataService; + let storagePersistenceService: StoragePersistenceService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(LoggerService), + mockProvider(UrlService), + mockProvider(DataService), + mockProvider(StoragePersistenceService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(ParService); + dataService = TestBed.inject(DataService); + loggerService = TestBed.inject(LoggerService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + urlService = TestBed.inject(UrlService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('postParRequest', () => { + it('throws error if authWellKnownEndPoints does not exist in storage', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of(null) + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue(null); + service.postParRequest({ configId: 'configId1' }).subscribe({ + error: (err) => { + expect(err.message).toBe( + 'Could not read PAR endpoint because authWellKnownEndPoints are not given' + ); + }, + }); + })); + + it('throws error if par endpoint does not exist in storage', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of(null) + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ some: 'thing' }); + service.postParRequest({ configId: 'configId1' }).subscribe({ + error: (err) => { + expect(err.message).toBe( + 'Could not read PAR endpoint from authWellKnownEndpoints' + ); + }, + }); + })); + + it('calls data service with correct params', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url123') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + + const dataServiceSpy = spyOn(dataService, 'post').and.returnValue(of({})); + + service.postParRequest({ configId: 'configId1' }).subscribe(() => { + expect(dataServiceSpy).toHaveBeenCalledOnceWith( + 'parEndpoint', + 'some-url123', + { configId: 'configId1' }, + jasmine.any(HttpHeaders) + ); + }); + })); + + it('Gives back correct object properties', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url456') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + spyOn(dataService, 'post').and.returnValue( + of({ expires_in: 123, request_uri: 'request_uri' }) + ); + service.postParRequest({ configId: 'configId1' }).subscribe((result) => { + expect(result).toEqual({ expiresIn: 123, requestUri: 'request_uri' }); + }); + })); + + it('throws error if data service has got an error', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url789') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + spyOn(dataService, 'post').and.returnValue( + throwError(() => new Error('ERROR')) + ); + const loggerSpy = spyOn(loggerService, 'logError'); + + service.postParRequest({ configId: 'configId1' }).subscribe({ + error: (err) => { + expect(err.message).toBe( + 'There was an error on ParService postParRequest' + ); + expect(loggerSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'There was an error on ParService postParRequest', + jasmine.any(Error) + ); + }, + }); + })); + + it('should retry once', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url456') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('ERROR')), + of({ expires_in: 123, request_uri: 'request_uri' }) + ) + ); + + service.postParRequest({ configId: 'configId1' }).subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(res).toEqual({ expiresIn: 123, requestUri: 'request_uri' }); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url456') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('ERROR')), + throwError(() => new Error('ERROR')), + of({ expires_in: 123, request_uri: 'request_uri' }) + ) + ); + + service.postParRequest({ configId: 'configId1' }).subscribe({ + next: (res) => { + expect(res).toBeTruthy(); + expect(res).toEqual({ expiresIn: 123, requestUri: 'request_uri' }); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + spyOn(urlService, 'createBodyForParCodeFlowRequest').and.returnValue( + of('some-url456') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ parEndpoint: 'parEndpoint' }); + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('ERROR')), + throwError(() => new Error('ERROR')), + throwError(() => new Error('ERROR')), + of({ expires_in: 123, request_uri: 'request_uri' }) + ) + ); + + service.postParRequest({ configId: 'configId1' }).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + }, + }); + })); + }); +}); diff --git a/src/login/par/par.service.ts b/src/login/par/par.service.ts new file mode 100644 index 0000000..9406487 --- /dev/null +++ b/src/login/par/par.service.ts @@ -0,0 +1,87 @@ +import { HttpHeaders } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map, retry, switchMap } from 'rxjs/operators'; +import { DataService } from '../../api/data.service'; +import { AuthOptions } from '../../auth-options'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { UrlService } from '../../utils/url/url.service'; +import { ParResponse } from './par-response'; + +@Injectable() +export class ParService { + private readonly loggerService = inject(LoggerService); + + private readonly urlService = inject(UrlService); + + private readonly dataService = inject(DataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + postParRequest( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): Observable { + let headers: HttpHeaders = new HttpHeaders(); + + headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); + + const authWellKnownEndpoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (!authWellKnownEndpoints) { + return throwError( + () => + new Error( + 'Could not read PAR endpoint because authWellKnownEndPoints are not given' + ) + ); + } + + const parEndpoint = authWellKnownEndpoints.parEndpoint; + + if (!parEndpoint) { + return throwError( + () => + new Error('Could not read PAR endpoint from authWellKnownEndpoints') + ); + } + + return this.urlService + .createBodyForParCodeFlowRequest(configuration, authOptions) + .pipe( + switchMap((data) => { + return this.dataService + .post(parEndpoint, data, configuration, headers) + .pipe( + retry(2), + map((response: any) => { + this.loggerService.logDebug( + configuration, + 'par response: ', + response + ); + + return { + expiresIn: response.expires_in, + requestUri: response.request_uri, + }; + }), + catchError((error) => { + const errorMessage = `There was an error on ParService postParRequest`; + + this.loggerService.logError(configuration, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }) + ); + }) + ); + } +} diff --git a/src/login/popup/popup-login.service.spec.ts b/src/login/popup/popup-login.service.spec.ts new file mode 100644 index 0000000..9d8116e --- /dev/null +++ b/src/login/popup/popup-login.service.spec.ts @@ -0,0 +1,222 @@ +import { CommonModule } from '@angular/common'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { CheckAuthService } from '../../auth-state/check-auth.service'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { LoggerService } from '../../logging/logger.service'; +import { UrlService } from '../../utils/url/url.service'; +import { LoginResponse } from '../login-response'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; +import { PopUpLoginService } from './popup-login.service'; +import { PopupResult } from './popup-result'; +import { PopUpService } from './popup.service'; + +describe('PopUpLoginService', () => { + let popUpLoginService: PopUpLoginService; + let urlService: UrlService; + let loggerService: LoggerService; + let responseTypValidationService: ResponseTypeValidationService; + let authWellKnownService: AuthWellKnownService; + let popupService: PopUpService; + let checkAuthService: CheckAuthService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CommonModule], + providers: [ + PopUpLoginService, + mockProvider(LoggerService), + mockProvider(ResponseTypeValidationService), + mockProvider(UrlService), + mockProvider(AuthWellKnownService), + mockProvider(PopUpService), + mockProvider(CheckAuthService), + ], + }); + }); + + beforeEach(() => { + popUpLoginService = TestBed.inject(PopUpLoginService); + urlService = TestBed.inject(UrlService); + loggerService = TestBed.inject(LoggerService); + responseTypValidationService = TestBed.inject( + ResponseTypeValidationService + ); + authWellKnownService = TestBed.inject(AuthWellKnownService); + popupService = TestBed.inject(PopUpService); + checkAuthService = TestBed.inject(CheckAuthService); + }); + + it('should create', () => { + expect(popUpLoginService).toBeTruthy(); + }); + + describe('loginWithPopUpStandard', () => { + it('does nothing if it has an invalid response type', waitForAsync(() => { + const config = { responseType: 'stubValue' }; + + spyOn( + responseTypValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(false); + const loggerSpy = spyOn(loggerService, 'logError'); + + popUpLoginService.loginWithPopUpStandard(config, [config]).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err.message).toBe('Invalid response type!'); + }, + }); + })); + + it('calls urlService.getAuthorizeUrl() if everything fits', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOnProperty(popupService, 'result$').and.returnValue( + of({} as PopupResult) + ); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + + popUpLoginService + .loginWithPopUpStandard(config, [config]) + .subscribe(() => { + expect(urlService.getAuthorizeUrl).toHaveBeenCalled(); + }); + })); + + it('opens popup if everything fits', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + spyOnProperty(popupService, 'result$').and.returnValue( + of({} as PopupResult) + ); + spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + const popupSpy = spyOn(popupService, 'openPopUp'); + + popUpLoginService + .loginWithPopUpStandard(config, [config]) + .subscribe(() => { + expect(popupSpy).toHaveBeenCalled(); + }); + })); + + it('returns three properties when popupservice received an url', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + spyOn(popupService, 'openPopUp'); + const checkAuthSpy = spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({ + isAuthenticated: true, + configId: 'configId1', + idToken: '', + userData: { any: 'userData' }, + accessToken: 'anyAccessToken', + }) + ); + const popupResult: PopupResult = { + userClosed: false, + receivedUrl: 'someUrl', + }; + + spyOnProperty(popupService, 'result$').and.returnValue(of(popupResult)); + + popUpLoginService + .loginWithPopUpStandard(config, [config]) + .subscribe((result) => { + expect(checkAuthSpy).toHaveBeenCalledOnceWith( + config, + [config], + 'someUrl' + ); + + expect(result).toEqual({ + isAuthenticated: true, + configId: 'configId1', + idToken: '', + userData: { any: 'userData' }, + accessToken: 'anyAccessToken', + }); + }); + })); + + it('returns two properties if popup was closed by user', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + configId: 'configId1', + }; + + spyOn( + responseTypValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + spyOn(popupService, 'openPopUp'); + const checkAuthSpy = spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + const popupResult = { userClosed: true } as PopupResult; + + spyOnProperty(popupService, 'result$').and.returnValue(of(popupResult)); + + popUpLoginService + .loginWithPopUpStandard(config, [config]) + .subscribe((result) => { + expect(checkAuthSpy).not.toHaveBeenCalled(); + expect(result).toEqual({ + isAuthenticated: false, + errorMessage: 'User closed popup', + configId: 'configId1', + idToken: '', + userData: null, + accessToken: '', + }); + }); + })); + }); +}); diff --git a/src/login/popup/popup-login.service.ts b/src/login/popup/popup-login.service.ts new file mode 100644 index 0000000..9a47674 --- /dev/null +++ b/src/login/popup/popup-login.service.ts @@ -0,0 +1,95 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { AuthOptions } from '../../auth-options'; +import { CheckAuthService } from '../../auth-state/check-auth.service'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { UrlService } from '../../utils/url/url.service'; +import { LoginResponse } from '../login-response'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; +import { PopupOptions } from './popup-options'; +import { PopupResult } from './popup-result'; +import { PopUpService } from './popup.service'; + +@Injectable() +export class PopUpLoginService { + private readonly loggerService = inject(LoggerService); + + private readonly responseTypeValidationService = inject( + ResponseTypeValidationService + ); + + private readonly urlService = inject(UrlService); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + private readonly popupService = inject(PopUpService); + + private readonly checkAuthService = inject(CheckAuthService); + + loginWithPopUpStandard( + configuration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + authOptions?: AuthOptions, + popupOptions?: PopupOptions + ): Observable { + const { configId } = configuration; + + if ( + !this.responseTypeValidationService.hasConfigValidResponseType( + configuration + ) + ) { + const errorMessage = 'Invalid response type!'; + + this.loggerService.logError(configuration, errorMessage); + + return throwError(() => new Error(errorMessage)); + } + + this.loggerService.logDebug( + configuration, + 'BEGIN Authorize OIDC Flow with popup, no auth data' + ); + + return this.authWellKnownService + .queryAndStoreAuthWellKnownEndPoints(configuration) + .pipe( + switchMap(() => + this.urlService.getAuthorizeUrl(configuration, authOptions) + ), + tap((authUrl) => + this.popupService.openPopUp(authUrl, popupOptions, configuration) + ), + switchMap(() => { + return this.popupService.result$.pipe( + take(1), + switchMap((result: PopupResult) => { + const { userClosed, receivedUrl } = result; + + if (userClosed) { + const response: LoginResponse = { + isAuthenticated: false, + errorMessage: 'User closed popup', + userData: null, + idToken: '', + accessToken: '', + configId, + }; + + return of(response); + } + + return this.checkAuthService.checkAuth( + configuration, + allConfigs, + receivedUrl + ); + }) + ); + }) + ); + } +} diff --git a/src/login/popup/popup-options.ts b/src/login/popup/popup-options.ts new file mode 100644 index 0000000..9852a4c --- /dev/null +++ b/src/login/popup/popup-options.ts @@ -0,0 +1,6 @@ +export interface PopupOptions { + width?: number; + height?: number; + left?: number; + top?: number; +} diff --git a/src/login/popup/popup-result.ts b/src/login/popup/popup-result.ts new file mode 100644 index 0000000..dbd4479 --- /dev/null +++ b/src/login/popup/popup-result.ts @@ -0,0 +1,4 @@ +export interface PopupResult { + userClosed: boolean; + receivedUrl: string; +} diff --git a/src/login/popup/popup.service.spec.ts b/src/login/popup/popup.service.spec.ts new file mode 100644 index 0000000..27d714e --- /dev/null +++ b/src/login/popup/popup.service.spec.ts @@ -0,0 +1,343 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { PopupResult } from './popup-result'; +import { PopUpService } from './popup.service'; + +describe('PopUpService', () => { + let popUpService: PopUpService; + let storagePersistenceService: StoragePersistenceService; + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(StoragePersistenceService), + mockProvider(LoggerService), + ], + }); + }); + + beforeEach(() => { + storagePersistenceService = TestBed.inject(StoragePersistenceService); + loggerService = TestBed.inject(LoggerService); + popUpService = TestBed.inject(PopUpService); + }); + + let store: any = {}; + const mockStorage = { + getItem: (key: string): string => { + return key in store ? store[key] : null; + }, + setItem: (key: string, value: string): void => { + store[key] = `${value}`; + }, + removeItem: (key: string): void => { + delete store[key]; + }, + clear: (): void => { + store = {}; + }, + length: 1, + key: (_i: any): string => '', + }; + + it('should create', () => { + expect(popUpService).toBeTruthy(); + }); + + describe('isCurrentlyInPopup', () => { + it('returns false if can not access Session Storage', () => { + // arrange + spyOn(popUpService as any, 'canAccessSessionStorage').and.returnValue( + false + ); + spyOnProperty(popUpService as any, 'windowInternal').and.returnValue({ + opener: {} as Window, + }); + spyOn(storagePersistenceService, 'read').and.returnValue({ + popupauth: true, + }); + const config = {} as OpenIdConfiguration; + + // act + const result = popUpService.isCurrentlyInPopup(config); + + // assert + expect(result).toBe(false); + }); + + it('returns false if window has no opener', () => { + // arrange + spyOn(popUpService as any, 'canAccessSessionStorage').and.returnValue( + true + ); + spyOn(storagePersistenceService, 'read').and.returnValue({ + popupauth: true, + }); + const config = {} as OpenIdConfiguration; + + // act + const result = popUpService.isCurrentlyInPopup(config); + + // assert + expect(result).toBe(false); + }); + + it('returns true if isCurrentlyInPopup', () => { + // arrange + spyOn(popUpService as any, 'canAccessSessionStorage').and.returnValue( + true + ); + spyOnProperty(popUpService as any, 'windowInternal').and.returnValue({ + opener: {} as Window, + }); + spyOn(storagePersistenceService, 'read').and.returnValue({ + popupauth: true, + }); + const config = {} as OpenIdConfiguration; + + // act + const result = popUpService.isCurrentlyInPopup(config); + + // assert + expect(result).toBe(true); + }); + }); + + describe('result$', () => { + it('emits when internal subject is called', waitForAsync(() => { + const popupResult: PopupResult = { + userClosed: false, + receivedUrl: 'some-url1111', + }; + + popUpService.result$.subscribe((result) => { + expect(result).toBe(popupResult); + }); + + (popUpService as any).resultInternal$.next(popupResult); + })); + }); + + describe('openPopup', () => { + it('popup opens with parameters and default options', waitForAsync(() => { + // arrange + const popupSpy = spyOn(window, 'open').and.callFake( + () => + ({ + closed: true, + close: () => undefined, + } as Window) + ); + + // act + popUpService.openPopUp('url', {}, { configId: 'configId1' }); + + // assert + expect(popupSpy).toHaveBeenCalledOnceWith( + 'url', + '_blank', + jasmine.any(String) + ); + })); + + it('popup opens with parameters and passed options', waitForAsync(() => { + // arrange + const popupSpy = spyOn(window, 'open').and.callFake( + () => + ({ + closed: true, + close: () => undefined, + } as Window) + ); + + // act + popUpService.openPopUp('url', { width: 100 }, { configId: 'configId1' }); + + // assert + expect(popupSpy).toHaveBeenCalledOnceWith( + 'url', + '_blank', + jasmine.any(String) + ); + })); + + it('logs error and return if popup could not be opened', () => { + // arrange + spyOn(window, 'open').and.callFake(() => null); + const loggerSpy = spyOn(loggerService, 'logError'); + + // act + popUpService.openPopUp('url', { width: 100 }, { configId: 'configId1' }); + + // assert + expect(loggerSpy).toHaveBeenCalledOnceWith( + { configId: 'configId1' }, + 'Could not open popup' + ); + }); + + describe('popup closed', () => { + let popup: Window; + let popupResult: PopupResult; + let cleanUpSpy: jasmine.Spy; + + beforeEach(() => { + popup = { + closed: false, + close: () => undefined, + } as Window; + + spyOn(window, 'open').and.returnValue(popup); + + cleanUpSpy = spyOn(popUpService as any, 'cleanUp').and.callThrough(); + + popupResult = {} as PopupResult; + + popUpService.result$.subscribe((result) => (popupResult = result)); + }); + + it('message received with data', fakeAsync(() => { + let listener: (event: MessageEvent) => void = () => { + return; + }; + + spyOn(window, 'addEventListener').and.callFake( + (_: any, func: any) => (listener = func) + ); + + popUpService.openPopUp('url', {}, { configId: 'configId1' }); + + expect(popupResult).toEqual({} as PopupResult); + expect(cleanUpSpy).not.toHaveBeenCalled(); + + listener(new MessageEvent('message', { data: 'some-url1111' })); + + tick(200); + + expect(popupResult).toEqual({ + userClosed: false, + receivedUrl: 'some-url1111', + }); + expect(cleanUpSpy).toHaveBeenCalledOnceWith(listener, { + configId: 'configId1', + }); + })); + + it('message received without data does return but cleanup does not throw event', fakeAsync(() => { + let listener: (event: MessageEvent) => void = () => { + return; + }; + + spyOn(window, 'addEventListener').and.callFake( + (_: any, func: any) => (listener = func) + ); + const nextSpy = spyOn((popUpService as any).resultInternal$, 'next'); + + popUpService.openPopUp('url', {}, { configId: 'configId1' }); + + expect(popupResult).toEqual({} as PopupResult); + expect(cleanUpSpy).not.toHaveBeenCalled(); + + listener(new MessageEvent('message', { data: null })); + + tick(200); + + expect(popupResult).toEqual({} as PopupResult); + expect(cleanUpSpy).toHaveBeenCalled(); + expect(nextSpy).not.toHaveBeenCalled(); + })); + + it('user closed', fakeAsync(() => { + popUpService.openPopUp('url', undefined, { configId: 'configId1' }); + + expect(popupResult).toEqual({} as PopupResult); + expect(cleanUpSpy).not.toHaveBeenCalled(); + + (popup as any).closed = true; + + tick(200); + + expect(popupResult).toEqual({ + userClosed: true, + receivedUrl: '', + } as PopupResult); + expect(cleanUpSpy).toHaveBeenCalled(); + })); + }); + }); + + describe('sendMessageToMainWindow', () => { + it('does nothing if window.opener is null', waitForAsync(() => { + // arrange + spyOnProperty(window, 'opener').and.returnValue(null); + + const sendMessageSpy = spyOn(popUpService as any, 'sendMessage'); + + // act + popUpService.sendMessageToMainWindow('', {}); + + // assert + expect(sendMessageSpy).not.toHaveBeenCalled(); + })); + + it('calls postMessage when window opener is given', waitForAsync(() => { + // arrange + spyOnProperty(window, 'opener').and.returnValue({ + postMessage: () => undefined, + }); + const sendMessageSpy = spyOn(window.opener, 'postMessage'); + + // act + popUpService.sendMessageToMainWindow('someUrl', {}); + + // assert + expect(sendMessageSpy).toHaveBeenCalledOnceWith( + 'someUrl', + jasmine.any(String) + ); + })); + }); + + describe('cleanUp', () => { + it('calls removeEventListener on window with correct params', waitForAsync(() => { + // arrange + const spy = spyOn(window, 'removeEventListener').and.callFake( + () => undefined + ); + const listener: any = null; + + // act + (popUpService as any).cleanUp(listener, { configId: 'configId1' }); + + // assert + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledOnceWith('message', listener, false); + })); + + it('removes popup from sessionstorage, closes and nulls when popup is opened', waitForAsync(() => { + // arrange + const popupMock = { + anyThing: 'truthy', + sessionStorage: mockStorage, + close: (): void => undefined, + }; + const removeItemSpy = spyOn(storagePersistenceService, 'remove'); + const closeSpy = spyOn(popupMock, 'close'); + + // act + (popUpService as any).popUp = popupMock; + (popUpService as any).cleanUp(null, { configId: 'configId1' }); + + // assert + expect(removeItemSpy).toHaveBeenCalledOnceWith('popupauth', { + configId: 'configId1', + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect((popUpService as any).popUp).toBeNull(); + })); + }); +}); diff --git a/src/login/popup/popup.service.ts b/src/login/popup/popup.service.ts new file mode 100644 index 0000000..3bf6f65 --- /dev/null +++ b/src/login/popup/popup.service.ts @@ -0,0 +1,223 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { Observable, Subject } from 'rxjs'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { PopupOptions } from './popup-options'; +import { PopupResult } from './popup-result'; + +@Injectable() +export class PopUpService { + private readonly loggerService = inject(LoggerService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly document = inject(DOCUMENT); + + private readonly STORAGE_IDENTIFIER = 'popupauth'; + + private popUp: Window | null = null; + + private handle = -1; + + private readonly resultInternal$ = new Subject(); + + get result$(): Observable { + return this.resultInternal$.asObservable(); + } + + private get windowInternal(): Window | null { + return this.document.defaultView; + } + + isCurrentlyInPopup(config: OpenIdConfiguration): boolean { + if (this.canAccessSessionStorage()) { + const popup = this.storagePersistenceService.read( + this.STORAGE_IDENTIFIER, + config + ); + + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return false; + } + + return ( + Boolean(windowIdentifier.opener) && + windowIdentifier.opener !== windowIdentifier && + Boolean(popup) + ); + } + + return false; + } + + openPopUp( + url: string | null, + popupOptions: PopupOptions | undefined, + config: OpenIdConfiguration + ): void { + const optionsToPass = this.getOptions(popupOptions); + + this.storagePersistenceService.write( + this.STORAGE_IDENTIFIER, + 'true', + config + ); + + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return; + } + + if (!url) { + this.loggerService.logError(config, 'Could not open popup, url is empty'); + + return; + } + + this.popUp = windowIdentifier.open(url, '_blank', optionsToPass); + + if (!this.popUp) { + this.storagePersistenceService.remove(this.STORAGE_IDENTIFIER, config); + this.loggerService.logError(config, 'Could not open popup'); + + return; + } + + this.loggerService.logDebug(config, 'Opened popup with url ' + url); + + const listener = (event: MessageEvent): void => { + if (!event?.data || typeof event.data !== 'string') { + if (config.disableCleaningPopupOnInvalidMessage) { + return; + } + this.cleanUp(listener, config); + + return; + } + + this.loggerService.logDebug( + config, + 'Received message from popup with url ' + event.data + ); + + this.resultInternal$.next({ userClosed: false, receivedUrl: event.data }); + + this.cleanUp(listener, config); + }; + + windowIdentifier.addEventListener('message', listener, false); + + this.handle = windowIdentifier.setInterval(() => { + if (this.popUp?.closed) { + this.resultInternal$.next({ userClosed: true, receivedUrl: '' }); + + this.cleanUp(listener, config); + } + }, 200); + } + + sendMessageToMainWindow(url: string, config: OpenIdConfiguration): void { + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return; + } + + if (windowIdentifier.opener) { + const href = windowIdentifier.location.href; + + this.sendMessage(url, href, config); + } + } + + private cleanUp(listener: any, config: OpenIdConfiguration): void { + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return; + } + + windowIdentifier.removeEventListener('message', listener, false); + windowIdentifier.clearInterval(this.handle); + + if (this.popUp) { + this.storagePersistenceService.remove(this.STORAGE_IDENTIFIER, config); + this.popUp.close(); + this.popUp = null; + } + } + + private sendMessage( + url: string, + href: string, + config: OpenIdConfiguration + ): void { + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return; + } + + if (!url) { + this.loggerService.logDebug( + config, + `Can not send message to parent, no url: '${url}'` + ); + + return; + } + + windowIdentifier.opener.postMessage(url, href); + } + + private getOptions(popupOptions: PopupOptions | undefined): string { + const popupDefaultOptions = { + width: 500, + height: 500, + left: 50, + top: 50, + }; + const options: PopupOptions = { + ...popupDefaultOptions, + ...(popupOptions || {}), + }; + const windowIdentifier = this.windowInternal; + + if (!windowIdentifier) { + return ''; + } + + const width = options.width || popupDefaultOptions.width; + const height = options.height || popupDefaultOptions.height; + + const left: number = + windowIdentifier.screenLeft + (windowIdentifier.outerWidth - width) / 2; + const top: number = + windowIdentifier.screenTop + (windowIdentifier.outerHeight - height) / 2; + + options.left = left; + options.top = top; + + return Object.entries(options) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join(','); + } + + private canAccessSessionStorage(): boolean { + return ( + typeof navigator !== 'undefined' && + navigator.cookieEnabled && + typeof Storage !== 'undefined' + ); + } +} diff --git a/src/login/response-type-validation/response-type-validation.service.spec.ts b/src/login/response-type-validation/response-type-validation.service.spec.ts new file mode 100644 index 0000000..679841c --- /dev/null +++ b/src/login/response-type-validation/response-type-validation.service.spec.ts @@ -0,0 +1,66 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { LoggerService } from '../../logging/logger.service'; +import { FlowHelper } from '../../utils/flowHelper/flow-helper.service'; +import { ResponseTypeValidationService } from './response-type-validation.service'; + +describe('ResponseTypeValidationService', () => { + let responseTypeValidationService: ResponseTypeValidationService; + let flowHelper: FlowHelper; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + ResponseTypeValidationService, + mockProvider(LoggerService), + mockProvider(FlowHelper), + ], + }); + }); + + beforeEach(() => { + responseTypeValidationService = TestBed.inject( + ResponseTypeValidationService + ); + flowHelper = TestBed.inject(FlowHelper); + }); + + it('should create', () => { + expect(responseTypeValidationService).toBeTruthy(); + }); + + describe('hasConfigValidResponseType', () => { + it('returns true if current configured flow is any implicit flow', () => { + spyOn(flowHelper, 'isCurrentFlowAnyImplicitFlow').and.returnValue(true); + + const result = responseTypeValidationService.hasConfigValidResponseType({ + configId: 'configId1', + }); + + expect(result).toEqual(true); + }); + + it('returns true if current configured flow is code flow', () => { + spyOn(flowHelper, 'isCurrentFlowAnyImplicitFlow').and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + + const result = responseTypeValidationService.hasConfigValidResponseType({ + configId: 'configId1', + }); + + expect(result).toEqual(true); + }); + + it('returns false if current configured flow is neither code nor implicit flow', () => { + spyOn(flowHelper, 'isCurrentFlowAnyImplicitFlow').and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + + const result = responseTypeValidationService.hasConfigValidResponseType({ + configId: 'configId1', + }); + + expect(result).toEqual(false); + }); + }); +}); diff --git a/src/login/response-type-validation/response-type-validation.service.ts b/src/login/response-type-validation/response-type-validation.service.ts new file mode 100644 index 0000000..0437ad2 --- /dev/null +++ b/src/login/response-type-validation/response-type-validation.service.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; +import { FlowHelper } from '../../utils/flowHelper/flow-helper.service'; + +@Injectable() +export class ResponseTypeValidationService { + private readonly loggerService = inject(LoggerService); + + private readonly flowHelper = inject(FlowHelper); + + hasConfigValidResponseType(configuration: OpenIdConfiguration): boolean { + if (this.flowHelper.isCurrentFlowAnyImplicitFlow(configuration)) { + return true; + } + + if (this.flowHelper.isCurrentFlowCodeFlow(configuration)) { + return true; + } + + this.loggerService.logWarning( + configuration, + 'module configured incorrectly, invalid response_type. Check the responseType in the config' + ); + + return false; + } +} diff --git a/src/login/standard/standard-login.service.spec.ts b/src/login/standard/standard-login.service.spec.ts new file mode 100644 index 0000000..4313ccc --- /dev/null +++ b/src/login/standard/standard-login.service.spec.ts @@ -0,0 +1,253 @@ +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { FlowsDataService } from '../../flows/flows-data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { RedirectService } from '../../utils/redirect/redirect.service'; +import { UrlService } from '../../utils/url/url.service'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; +import { StandardLoginService } from './standard-login.service'; + +describe('StandardLoginService', () => { + let standardLoginService: StandardLoginService; + let loggerService: LoggerService; + let responseTypeValidationService: ResponseTypeValidationService; + let urlService: UrlService; + let redirectService: RedirectService; + let authWellKnownService: AuthWellKnownService; + let flowsDataService: FlowsDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + StandardLoginService, + mockProvider(LoggerService), + mockProvider(ResponseTypeValidationService), + mockProvider(UrlService), + mockProvider(RedirectService), + mockProvider(AuthWellKnownService), + mockProvider(FlowsDataService), + ], + }); + }); + + beforeEach(() => { + standardLoginService = TestBed.inject(StandardLoginService); + loggerService = TestBed.inject(LoggerService); + responseTypeValidationService = TestBed.inject( + ResponseTypeValidationService + ); + standardLoginService = TestBed.inject(StandardLoginService); + urlService = TestBed.inject(UrlService); + redirectService = TestBed.inject(RedirectService); + authWellKnownService = TestBed.inject(AuthWellKnownService); + flowsDataService = TestBed.inject(FlowsDataService); + }); + + it('should create', () => { + expect(standardLoginService).toBeTruthy(); + }); + + describe('loginStandard', () => { + it('does nothing if it has an invalid response type', waitForAsync(() => { + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(false); + const loggerSpy = spyOn(loggerService, 'logError'); + + const result = standardLoginService.loginStandard({ + configId: 'configId1', + }); + + expect(result).toBeUndefined(); + expect(loggerSpy).toHaveBeenCalled(); + })); + + it('calls flowsDataService.setCodeFlowInProgress() if everything fits', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + const flowsDataSpy = spyOn(flowsDataService, 'setCodeFlowInProgress'); + + const result = standardLoginService.loginStandard(config); + + expect(result).toBeUndefined(); + expect(flowsDataSpy).toHaveBeenCalled(); + })); + + it('calls urlService.getAuthorizeUrl() if everything fits', waitForAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + + const result = standardLoginService.loginStandard(config); + + expect(result).toBeUndefined(); + })); + + it('redirects to URL with no URL handler', fakeAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + const redirectSpy = spyOn( + redirectService, + 'redirectTo' + ).and.callThrough(); + + standardLoginService.loginStandard(config); + tick(); + expect(redirectSpy).toHaveBeenCalledOnceWith('someUrl'); + })); + + it('redirects to URL with URL handler when urlHandler is given', fakeAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + const redirectSpy = spyOn(redirectService, 'redirectTo').and.callFake( + () => undefined + ); + const spy = jasmine.createSpy(); + const urlHandler = (url: any): void => { + spy(url); + }; + + standardLoginService.loginStandard(config, { urlHandler }); + tick(); + expect(spy).toHaveBeenCalledOnceWith('someUrl'); + expect(redirectSpy).not.toHaveBeenCalled(); + })); + + it('calls resetSilentRenewRunning', fakeAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('someUrl')); + const flowsDataSpy = spyOn(flowsDataService, 'resetSilentRenewRunning'); + + standardLoginService.loginStandard(config, {}); + tick(); + + expect(flowsDataSpy).toHaveBeenCalled(); + })); + + it('calls getAuthorizeUrl with custom params if they are given as parameter', fakeAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + const getAuthorizeUrlSpy = spyOn( + urlService, + 'getAuthorizeUrl' + ).and.returnValue(of('someUrl')); + const redirectSpy = spyOn(redirectService, 'redirectTo').and.callFake( + () => undefined + ); + + standardLoginService.loginStandard(config, { + customParams: { to: 'add', as: 'well' }, + }); + tick(); + expect(redirectSpy).toHaveBeenCalledOnceWith('someUrl'); + expect(getAuthorizeUrlSpy).toHaveBeenCalledOnceWith(config, { + customParams: { to: 'add', as: 'well' }, + }); + })); + + it('does nothing, logs only if getAuthorizeUrl returns falsy', fakeAsync(() => { + const config = { + authWellknownEndpointUrl: 'authWellknownEndpoint', + responseType: 'stubValue', + }; + + spyOn( + responseTypeValidationService, + 'hasConfigValidResponseType' + ).and.returnValue(true); + spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + const loggerSpy = spyOn(loggerService, 'logError'); + + spyOn(urlService, 'getAuthorizeUrl').and.returnValue(of('')); + const redirectSpy = spyOn(redirectService, 'redirectTo').and.callFake( + () => undefined + ); + + standardLoginService.loginStandard(config); + tick(); + expect(loggerSpy).toHaveBeenCalledOnceWith( + config, + 'Could not create URL', + '' + ); + expect(redirectSpy).not.toHaveBeenCalled(); + })); + }); +}); diff --git a/src/login/standard/standard-login.service.ts b/src/login/standard/standard-login.service.ts new file mode 100644 index 0000000..d4e230d --- /dev/null +++ b/src/login/standard/standard-login.service.ts @@ -0,0 +1,75 @@ +import { inject, Injectable } from 'injection-js'; +import { AuthOptions } from '../../auth-options'; +import { AuthWellKnownService } from '../../config/auth-well-known/auth-well-known.service'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { FlowsDataService } from '../../flows/flows-data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { RedirectService } from '../../utils/redirect/redirect.service'; +import { UrlService } from '../../utils/url/url.service'; +import { ResponseTypeValidationService } from '../response-type-validation/response-type-validation.service'; + +@Injectable() +export class StandardLoginService { + private readonly loggerService = inject(LoggerService); + + private readonly responseTypeValidationService = inject( + ResponseTypeValidationService + ); + + private readonly urlService = inject(UrlService); + + private readonly redirectService = inject(RedirectService); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + private readonly flowsDataService = inject(FlowsDataService); + + loginStandard( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): void { + if ( + !this.responseTypeValidationService.hasConfigValidResponseType( + configuration + ) + ) { + this.loggerService.logError(configuration, 'Invalid response type!'); + + return; + } + + this.loggerService.logDebug( + configuration, + 'BEGIN Authorize OIDC Flow, no auth data' + ); + this.flowsDataService.setCodeFlowInProgress(configuration); + + this.authWellKnownService + .queryAndStoreAuthWellKnownEndPoints(configuration) + .subscribe(() => { + const { urlHandler } = authOptions || {}; + + this.flowsDataService.resetSilentRenewRunning(configuration); + + this.urlService + .getAuthorizeUrl(configuration, authOptions) + .subscribe((url) => { + if (!url) { + this.loggerService.logError( + configuration, + 'Could not create URL', + url + ); + + return; + } + + if (urlHandler) { + urlHandler(url); + } else { + this.redirectService.redirectTo(url); + } + }); + }); + } +} diff --git a/src/logoff-revoke/logoff-revocation.service.spec.ts b/src/logoff-revoke/logoff-revocation.service.spec.ts new file mode 100644 index 0000000..94d6299 --- /dev/null +++ b/src/logoff-revoke/logoff-revocation.service.spec.ts @@ -0,0 +1,825 @@ +import { HttpHeaders } from '@angular/common/http'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Observable, of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { createRetriableStream } from '../../test/create-retriable-stream.helper'; +import { DataService } from '../api/data.service'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { CheckSessionService } from '../iframe/check-session.service'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { RedirectService } from '../utils/redirect/redirect.service'; +import { UrlService } from '../utils/url/url.service'; +import { LogoffRevocationService } from './logoff-revocation.service'; + +describe('Logout and Revoke Service', () => { + let service: LogoffRevocationService; + let dataService: DataService; + let loggerService: LoggerService; + let storagePersistenceService: StoragePersistenceService; + let urlService: UrlService; + let checkSessionService: CheckSessionService; + let resetAuthDataService: ResetAuthDataService; + let redirectService: RedirectService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(DataService), + mockProvider(LoggerService), + mockProvider(StoragePersistenceService), + mockProvider(UrlService), + mockProvider(CheckSessionService), + mockProvider(ResetAuthDataService), + mockProvider(RedirectService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(LogoffRevocationService); + dataService = TestBed.inject(DataService); + loggerService = TestBed.inject(LoggerService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + urlService = TestBed.inject(UrlService); + checkSessionService = TestBed.inject(CheckSessionService); + resetAuthDataService = TestBed.inject(ResetAuthDataService); + redirectService = TestBed.inject(RedirectService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('revokeAccessToken', () => { + it('uses token parameter if token as parameter is passed in the method', () => { + // Arrange + const paramToken = 'passedTokenAsParam'; + const revocationSpy = spyOn( + urlService, + 'createRevocationEndpointBodyAccessToken' + ); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue(of(null)); + + // Act + service.revokeAccessToken(config, paramToken); + // Assert + expect(revocationSpy).toHaveBeenCalledOnceWith(paramToken, config); + }); + + it('uses token parameter from persistence if no param is provided', () => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + const revocationSpy = spyOn( + urlService, + 'createRevocationEndpointBodyAccessToken' + ); + + spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1' }; + + // Act + service.revokeAccessToken(config); + // Assert + expect(revocationSpy).toHaveBeenCalledOnceWith(paramToken, config); + }); + + it('returns type observable', () => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1' }; + + // Act + const result = service.revokeAccessToken(config); + + // Assert + expect(result).toEqual(jasmine.any(Observable)); + }); + + it('loggs and returns unmodified response if request is positive', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + + spyOn(dataService, 'post').and.returnValue(of({ data: 'anything' })); + const config = { configId: 'configId1' }; + + // Act + service.revokeAccessToken(config).subscribe((result) => { + // Assert + expect(result).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }); + })); + + it('loggs error when request is negative', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logError'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + throwError(() => new Error('Error')) + ); + + // Act + service.revokeAccessToken(config).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it('should retry once', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeAccessToken(config).subscribe({ + next: (res) => { + // Assert + expect(res).toBeTruthy(); + expect(res).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeAccessToken(config).subscribe({ + next: (res) => { + // Assert + expect(res).toBeTruthy(); + expect(res).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logError'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeAccessToken(config).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + }); + + describe('revokeRefreshToken', () => { + it('uses refresh token parameter if token as parameter is passed in the method', () => { + // Arrange + const paramToken = 'passedTokenAsParam'; + const revocationSpy = spyOn( + urlService, + 'createRevocationEndpointBodyRefreshToken' + ); + + spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1' }; + + // Act + service.revokeRefreshToken(config, paramToken); + // Assert + expect(revocationSpy).toHaveBeenCalledOnceWith(paramToken, config); + }); + + it('uses refresh token parameter from persistence if no param is provided', () => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + const config = { configId: 'configId1' }; + const revocationSpy = spyOn( + urlService, + 'createRevocationEndpointBodyRefreshToken' + ); + + spyOn(dataService, 'post').and.returnValue(of(null)); + // Act + service.revokeRefreshToken(config); + // Assert + expect(revocationSpy).toHaveBeenCalledOnceWith(paramToken, config); + }); + + it('returns type observable', () => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1' }; + + // Act + const result = service.revokeRefreshToken(config); + + // Assert + expect(result).toEqual(jasmine.any(Observable)); + }); + + it('loggs and returns unmodified response if request is positive', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + + spyOn(dataService, 'post').and.returnValue(of({ data: 'anything' })); + const config = { configId: 'configId1' }; + + // Act + service.revokeRefreshToken(config).subscribe((result) => { + // Assert + expect(result).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }); + })); + + it('loggs error when request is negative', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logError'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + throwError(() => new Error('Error')) + ); + + // Act + service.revokeRefreshToken(config).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it('should retry once', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeRefreshToken(config).subscribe({ + next: (res) => { + // Assert + expect(res).toBeTruthy(); + expect(res).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logDebug'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeRefreshToken(config).subscribe({ + next: (res) => { + // Assert + expect(res).toBeTruthy(); + expect(res).toEqual({ data: 'anything' }); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(urlService, 'createRevocationEndpointBodyAccessToken'); + const loggerSpy = spyOn(loggerService, 'logError'); + const config = { configId: 'configId1' }; + + spyOn(dataService, 'post').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of({ data: 'anything' }) + ) + ); + + service.revokeRefreshToken(config).subscribe({ + error: (err) => { + expect(err).toBeTruthy(); + expect(loggerSpy).toHaveBeenCalled(); + }, + }); + })); + }); + + describe('logoff', () => { + it('logs and returns if `endSessionUrl` is false', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue(''); + + const serverStateChangedSpy = spyOn( + checkSessionService, + 'serverStateChanged' + ); + const config = { configId: 'configId1' }; + + // Act + const result$ = service.logoff(config, [config]); + + // Assert + result$.subscribe(() => { + expect(serverStateChangedSpy).not.toHaveBeenCalled(); + }); + })); + + it('logs and returns if `serverStateChanged` is true', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + const redirectSpy = spyOn(redirectService, 'redirectTo'); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(true); + const config = { configId: 'configId1' }; + + // Act + const result$ = service.logoff(config, [config]); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).not.toHaveBeenCalled(); + }); + })); + + it('calls urlHandler if urlhandler is passed', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + const spy = jasmine.createSpy(); + const urlHandler = (url: string): void => { + spy(url); + }; + const redirectSpy = spyOn(redirectService, 'redirectTo'); + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(false); + const config = { configId: 'configId1' }; + + // Act + const result$ = service.logoff(config, [config], { urlHandler }); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledOnceWith('someValue'); + expect(resetAuthorizationDataSpy).toHaveBeenCalled(); + }); + })); + + it('calls redirect service if no logoutOptions are passed', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + + const redirectSpy = spyOn(redirectService, 'redirectTo'); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(false); + const config = { configId: 'configId1' }; + + // Act + const result$ = service.logoff(config, [config]); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).toHaveBeenCalledOnceWith('someValue'); + }); + })); + + it('calls redirect service if logoutOptions are passed and method is GET', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + + const redirectSpy = spyOn(redirectService, 'redirectTo'); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(false); + const config = { configId: 'configId1' }; + + // Act + const result$ = service.logoff(config, [config], { logoffMethod: 'GET' }); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).toHaveBeenCalledOnceWith('someValue'); + }); + })); + + it('calls dataservice post if logoutOptions are passed and method is POST', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + + const redirectSpy = spyOn(redirectService, 'redirectTo'); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(false); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'id-token' + ); + spyOn(urlService, 'getPostLogoutRedirectUrl').and.returnValue( + 'post-logout-redirect-url' + ); + spyOn(urlService, 'getEndSessionEndpoint').and.returnValue({ + url: 'some-url', + existingParams: '', + }); + const postSpy = spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1', clientId: 'clientId' }; + + // Act + const result$ = service.logoff(config, [config], { + logoffMethod: 'POST', + }); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).not.toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledOnceWith( + 'some-url', + { + id_token_hint: 'id-token', + client_id: 'clientId', + post_logout_redirect_uri: 'post-logout-redirect-url', + }, + config, + jasmine.anything() + ); + + const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders; + + expect(httpHeaders.has('Content-Type')).toBeTrue(); + expect(httpHeaders.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + }); + })); + + it('calls dataservice post if logoutOptions with customParams are passed and method is POST', waitForAsync(() => { + // Arrange + spyOn(urlService, 'getEndSessionUrl').and.returnValue('someValue'); + + const redirectSpy = spyOn(redirectService, 'redirectTo'); + + spyOn(checkSessionService, 'serverStateChanged').and.returnValue(false); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'id-token' + ); + spyOn(urlService, 'getPostLogoutRedirectUrl').and.returnValue( + 'post-logout-redirect-url' + ); + spyOn(urlService, 'getEndSessionEndpoint').and.returnValue({ + url: 'some-url', + existingParams: '', + }); + const postSpy = spyOn(dataService, 'post').and.returnValue(of(null)); + const config = { configId: 'configId1', clientId: 'clientId' }; + + // Act + const result$ = service.logoff(config, [config], { + logoffMethod: 'POST', + customParams: { + state: 'state', + logout_hint: 'logoutHint', + ui_locales: 'de fr en', + }, + }); + + // Assert + result$.subscribe(() => { + expect(redirectSpy).not.toHaveBeenCalled(); + expect(postSpy).toHaveBeenCalledOnceWith( + 'some-url', + { + id_token_hint: 'id-token', + client_id: 'clientId', + post_logout_redirect_uri: 'post-logout-redirect-url', + state: 'state', + logout_hint: 'logoutHint', + ui_locales: 'de fr en', + }, + config, + jasmine.anything() + ); + + const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders; + + expect(httpHeaders.has('Content-Type')).toBeTrue(); + expect(httpHeaders.get('Content-Type')).toBe( + 'application/x-www-form-urlencoded' + ); + }); + })); + }); + + describe('logoffLocal', () => { + it('calls flowsService.resetAuthorizationData', () => { + // Arrange + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const config = { configId: 'configId1' }; + + // Act + service.logoffLocal(config, [config]); + + // Assert + expect(resetAuthorizationDataSpy).toHaveBeenCalled(); + }); + }); + + describe('logoffAndRevokeTokens', () => { + it('calls revokeRefreshToken and revokeAccessToken when storage holds a refreshtoken', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ revocationEndpoint: 'revocationEndpoint' }); + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + const revokeRefreshTokenSpy = spyOn( + service, + 'revokeRefreshToken' + ).and.returnValue(of({ any: 'thing' })); + const revokeAccessTokenSpy = spyOn( + service, + 'revokeAccessToken' + ).and.returnValue(of({ any: 'thing' })); + + // Act + service.logoffAndRevokeTokens(config, [config]).subscribe(() => { + // Assert + expect(revokeRefreshTokenSpy).toHaveBeenCalled(); + expect(revokeAccessTokenSpy).toHaveBeenCalled(); + }); + })); + + it('logs error when revokeaccesstoken throws an error', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ revocationEndpoint: 'revocationEndpoint' }); + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(service, 'revokeRefreshToken').and.returnValue( + of({ any: 'thing' }) + ); + const loggerSpy = spyOn(loggerService, 'logError'); + + spyOn(service, 'revokeAccessToken').and.returnValue( + throwError(() => new Error('Error')) + ); + + // Act + service.logoffAndRevokeTokens(config, [config]).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + + it('calls logoff in case of success', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(service, 'revokeRefreshToken').and.returnValue( + of({ any: 'thing' }) + ); + spyOn(service, 'revokeAccessToken').and.returnValue(of({ any: 'thing' })); + const logoffSpy = spyOn(service, 'logoff').and.returnValue(of(null)); + const config = { configId: 'configId1' }; + + // Act + service.logoffAndRevokeTokens(config, [config]).subscribe(() => { + // Assert + expect(logoffSpy).toHaveBeenCalled(); + }); + })); + + it('calls logoff with urlhandler in case of success', waitForAsync(() => { + // Arrange + const paramToken = 'damien'; + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue( + paramToken + ); + spyOn(service, 'revokeRefreshToken').and.returnValue( + of({ any: 'thing' }) + ); + spyOn(service, 'revokeAccessToken').and.returnValue(of({ any: 'thing' })); + const logoffSpy = spyOn(service, 'logoff').and.returnValue(of(null)); + const urlHandler = (_url: string): void => undefined; + const config = { configId: 'configId1' }; + + // Act + service + .logoffAndRevokeTokens(config, [config], { urlHandler }) + .subscribe(() => { + // Assert + expect(logoffSpy).toHaveBeenCalledOnceWith(config, [config], { + urlHandler, + }); + }); + })); + + it('calls revokeAccessToken when storage does not hold a refreshtoken', waitForAsync(() => { + // Arrange + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ revocationEndpoint: 'revocationEndpoint' }); + + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue(''); + const revokeRefreshTokenSpy = spyOn(service, 'revokeRefreshToken'); + const revokeAccessTokenSpy = spyOn( + service, + 'revokeAccessToken' + ).and.returnValue(of({ any: 'thing' })); + + // Act + service.logoffAndRevokeTokens(config, [config]).subscribe(() => { + // Assert + expect(revokeRefreshTokenSpy).not.toHaveBeenCalled(); + expect(revokeAccessTokenSpy).toHaveBeenCalled(); + }); + })); + + it('logs error when revokeaccesstoken throws an error', waitForAsync(() => { + // Arrange + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ revocationEndpoint: 'revocationEndpoint' }); + spyOn(storagePersistenceService, 'getRefreshToken').and.returnValue(''); + const loggerSpy = spyOn(loggerService, 'logError'); + + spyOn(service, 'revokeAccessToken').and.returnValue( + throwError(() => new Error('Error')) + ); + + // Act + service.logoffAndRevokeTokens(config, [config]).subscribe({ + error: (err) => { + expect(loggerSpy).toHaveBeenCalled(); + expect(err).toBeTruthy(); + }, + }); + })); + }); + + describe('logoffLocalMultiple', () => { + it('calls logoffLocal for every config which is present', () => { + // Arrange + const allConfigs = [{ configId: 'configId1' }, { configId: 'configId2' }]; + const resetAuthorizationDataSpy = spyOn( + resetAuthDataService, + 'resetAuthorizationData' + ); + const checkSessionServiceSpy = spyOn(checkSessionService, 'stop'); + + // Act + service.logoffLocalMultiple(allConfigs); + + // Assert + expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(2); + expect(checkSessionServiceSpy).toHaveBeenCalledTimes(2); + expect(resetAuthorizationDataSpy.calls.allArgs()).toEqual([ + [allConfigs[0], allConfigs], + [allConfigs[1], allConfigs], + ]); + }); + }); +}); diff --git a/src/logoff-revoke/logoff-revocation.service.ts b/src/logoff-revoke/logoff-revocation.service.ts new file mode 100644 index 0000000..b3187d6 --- /dev/null +++ b/src/logoff-revoke/logoff-revocation.service.ts @@ -0,0 +1,300 @@ +import { HttpHeaders } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, concatMap, retry, switchMap } from 'rxjs/operators'; +import { DataService } from '../api/data.service'; +import { LogoutAuthOptions } from '../auth-options'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { ResetAuthDataService } from '../flows/reset-auth-data.service'; +import { CheckSessionService } from '../iframe/check-session.service'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { removeNullAndUndefinedValues } from '../utils/object/object.helper'; +import { RedirectService } from '../utils/redirect/redirect.service'; +import { UrlService } from '../utils/url/url.service'; + +@Injectable() +export class LogoffRevocationService { + private readonly loggerService = inject(LoggerService); + + private readonly dataService = inject(DataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly urlService = inject(UrlService); + + private readonly checkSessionService = inject(CheckSessionService); + + private readonly resetAuthDataService = inject(ResetAuthDataService); + + private readonly redirectService = inject(RedirectService); + + // Logs out on the server and the local client. + // If the server state has changed, check session, then only a local logout. + logoff( + config: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[], + logoutAuthOptions?: LogoutAuthOptions + ): Observable { + if (!config) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + this.loggerService.logDebug( + config, + 'logoff, remove auth', + logoutAuthOptions + ); + + const { urlHandler, customParams } = logoutAuthOptions || {}; + + const endSessionUrl = this.urlService.getEndSessionUrl( + config, + customParams + ); + + if (!endSessionUrl) { + this.loggerService.logDebug( + config, + 'No endsessionUrl present. Logoff was only locally. Returning.' + ); + + return of(null); + } + + if (this.checkSessionService.serverStateChanged(config)) { + this.loggerService.logDebug( + config, + 'Server State changed. Logoff was only locally. Returning.' + ); + + return of(null); + } + + if (urlHandler) { + this.loggerService.logDebug( + config, + `Custom UrlHandler found. Using this to handle logoff with url '${endSessionUrl}'` + ); + urlHandler(endSessionUrl); + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + + return of(null); + } + + return this.logoffInternal( + logoutAuthOptions, + endSessionUrl, + config, + allConfigs + ); + } + + logoffLocal( + config: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[] + ): void { + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + this.checkSessionService.stop(); + } + + logoffLocalMultiple(allConfigs: OpenIdConfiguration[]): void { + allConfigs.forEach((configuration) => + this.logoffLocal(configuration, allConfigs) + ); + } + + // The refresh token and and the access token are revoked on the server. If the refresh token does not exist + // only the access token is revoked. Then the logout run. + logoffAndRevokeTokens( + config: OpenIdConfiguration | null, + allConfigs: OpenIdConfiguration[], + logoutAuthOptions?: LogoutAuthOptions + ): Observable { + if (!config) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + const { revocationEndpoint } = + this.storagePersistenceService.read('authWellKnownEndPoints', config) || + {}; + + if (!revocationEndpoint) { + this.loggerService.logDebug(config, 'revocation endpoint not supported'); + + return this.logoff(config, allConfigs, logoutAuthOptions); + } + + if (this.storagePersistenceService.getRefreshToken(config)) { + return this.revokeRefreshToken(config).pipe( + switchMap((_) => this.revokeAccessToken(config)), + catchError((error) => { + const errorMessage = `revoke token failed`; + + this.loggerService.logError(config, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }), + concatMap(() => this.logoff(config, allConfigs, logoutAuthOptions)) + ); + } else { + return this.revokeAccessToken(config).pipe( + catchError((error) => { + const errorMessage = `revoke accessToken failed`; + + this.loggerService.logError(config, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }), + concatMap(() => this.logoff(config, allConfigs, logoutAuthOptions)) + ); + } + } + + // https://tools.ietf.org/html/rfc7009 + // revokes an access token on the STS. If no token is provided, then the token from + // the storage is revoked. You can pass any token to revoke. This makes it possible to + // manage your own tokens. The is a public API. + revokeAccessToken( + configuration: OpenIdConfiguration | null, + accessToken?: any + ): Observable { + if (!configuration) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + const accessTok = + accessToken || + this.storagePersistenceService.getAccessToken(configuration); + const body = this.urlService.createRevocationEndpointBodyAccessToken( + accessTok, + configuration + ); + + return this.sendRevokeRequest(configuration, body); + } + + // https://tools.ietf.org/html/rfc7009 + // revokes an refresh token on the STS. This is only required in the code flow with refresh tokens. + // If no token is provided, then the token from the storage is revoked. You can pass any token to revoke. + // This makes it possible to manage your own tokens. + revokeRefreshToken( + configuration: OpenIdConfiguration | null, + refreshToken?: any + ): Observable { + if (!configuration) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + const refreshTok = + refreshToken || + this.storagePersistenceService.getRefreshToken(configuration); + const body = this.urlService.createRevocationEndpointBodyRefreshToken( + refreshTok, + configuration + ); + + return this.sendRevokeRequest(configuration, body); + } + + private logoffInternal( + logoutAuthOptions: LogoutAuthOptions | undefined, + endSessionUrl: string, + config: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + const { logoffMethod, customParams } = logoutAuthOptions || {}; + + if (!logoffMethod || logoffMethod === 'GET') { + this.redirectService.redirectTo(endSessionUrl); + + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + + return of(null); + } + + const { state, logout_hint, ui_locales } = customParams || {}; + const { clientId } = config; + const idToken = this.storagePersistenceService.getIdToken(config); + const postLogoutRedirectUrl = + this.urlService.getPostLogoutRedirectUrl(config); + const headers = this.getHeaders(); + const { url } = this.urlService.getEndSessionEndpoint(config); + const body = { + id_token_hint: idToken, + client_id: clientId, + post_logout_redirect_uri: postLogoutRedirectUrl, + state, + logout_hint, + ui_locales, + }; + const bodyWithoutNullOrUndefined = removeNullAndUndefinedValues(body); + + this.resetAuthDataService.resetAuthorizationData(config, allConfigs); + + return this.dataService.post( + url, + bodyWithoutNullOrUndefined, + config, + headers + ); + } + + private sendRevokeRequest( + configuration: OpenIdConfiguration, + body: string | null + ): Observable { + const url = this.urlService.getRevocationEndpointUrl(configuration); + const headers = this.getHeaders(); + + return this.dataService.post(url, body, configuration, headers).pipe( + retry(2), + switchMap((response: any) => { + this.loggerService.logDebug( + configuration, + 'revocation endpoint post response: ', + response + ); + + return of(response); + }), + catchError((error) => { + const errorMessage = `Revocation request failed`; + + this.loggerService.logError(configuration, errorMessage, error); + + return throwError(() => new Error(errorMessage)); + }) + ); + } + + private getHeaders(): HttpHeaders { + let headers: HttpHeaders = new HttpHeaders(); + + headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); + + return headers; + } +} diff --git a/src/oidc.security.service.spec.ts b/src/oidc.security.service.spec.ts new file mode 100644 index 0000000..e9d124d --- /dev/null +++ b/src/oidc.security.service.spec.ts @@ -0,0 +1,794 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; +import { mockProvider } from '../test/auto-mock'; +import { AuthStateService } from './auth-state/auth-state.service'; +import { CheckAuthService } from './auth-state/check-auth.service'; +import { CallbackService } from './callback/callback.service'; +import { RefreshSessionService } from './callback/refresh-session.service'; +import { AuthWellKnownService } from './config/auth-well-known/auth-well-known.service'; +import { ConfigurationService } from './config/config.service'; +import { FlowsDataService } from './flows/flows-data.service'; +import { CheckSessionService } from './iframe/check-session.service'; +import { LoginResponse } from './login/login-response'; +import { LoginService } from './login/login.service'; +import { LogoffRevocationService } from './logoff-revoke/logoff-revocation.service'; +import { OidcSecurityService } from './oidc.security.service'; +import { UserService } from './user-data/user.service'; +import { TokenHelperService } from './utils/tokenHelper/token-helper.service'; +import { UrlService } from './utils/url/url.service'; + +describe('OidcSecurityService', () => { + let oidcSecurityService: OidcSecurityService; + let configurationService: ConfigurationService; + let authStateService: AuthStateService; + let authWellKnownService: AuthWellKnownService; + let tokenHelperService: TokenHelperService; + let flowsDataService: FlowsDataService; + let logoffRevocationService: LogoffRevocationService; + let loginService: LoginService; + let refreshSessionService: RefreshSessionService; + let checkAuthService: CheckAuthService; + let checkSessionService: CheckSessionService; + let userService: UserService; + let urlService: UrlService; + let callbackService: CallbackService; + let authenticatedSpy: jasmine.Spy; + let userDataSpy: jasmine.Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + OidcSecurityService, + mockProvider(CheckSessionService), + mockProvider(CheckAuthService), + mockProvider(UserService), + mockProvider(TokenHelperService), + mockProvider(ConfigurationService), + mockProvider(AuthStateService), + mockProvider(FlowsDataService), + mockProvider(CallbackService), + mockProvider(LogoffRevocationService), + mockProvider(LoginService), + mockProvider(RefreshSessionService), + mockProvider(UrlService), + mockProvider(AuthWellKnownService), + ], + }); + }); + + beforeEach(() => { + authStateService = TestBed.inject(AuthStateService); + tokenHelperService = TestBed.inject(TokenHelperService); + configurationService = TestBed.inject(ConfigurationService); + flowsDataService = TestBed.inject(FlowsDataService); + logoffRevocationService = TestBed.inject(LogoffRevocationService); + loginService = TestBed.inject(LoginService); + refreshSessionService = TestBed.inject(RefreshSessionService); + checkAuthService = TestBed.inject(CheckAuthService); + userService = TestBed.inject(UserService); + urlService = TestBed.inject(UrlService); + authWellKnownService = TestBed.inject(AuthWellKnownService); + checkSessionService = TestBed.inject(CheckSessionService); + callbackService = TestBed.inject(CallbackService); + + // this is required because these methods will be invoked by the signal properties when the service is created + authenticatedSpy = spyOnProperty( + authStateService, + 'authenticated$' + ).and.returnValue( + of({ isAuthenticated: false, allConfigsAuthenticated: [] }) + ); + userDataSpy = spyOnProperty(userService, 'userData$').and.returnValue( + of({ userData: null, allUserData: [] }) + ); + oidcSecurityService = TestBed.inject(OidcSecurityService); + }); + + it('should create', () => { + expect(oidcSecurityService).toBeTruthy(); + }); + + describe('userData$', () => { + it('calls userService.userData$', waitForAsync(() => { + oidcSecurityService.userData$.subscribe(() => { + // 1x from this subscribe + // 1x by the signal property + expect(userDataSpy).toHaveBeenCalledTimes(2); + }); + })); + }); + + describe('userData', () => { + it('calls userService.userData$', waitForAsync(() => { + const _userdata = oidcSecurityService.userData(); + + expect(userDataSpy).toHaveBeenCalledTimes(1); + })); + }); + + describe('isAuthenticated$', () => { + it('calls authStateService.isAuthenticated$', waitForAsync(() => { + oidcSecurityService.isAuthenticated$.subscribe(() => { + // 1x from this subscribe + // 1x by the signal property + expect(authenticatedSpy).toHaveBeenCalledTimes(2); + }); + })); + }); + + describe('authenticated', () => { + it('calls authStateService.isAuthenticated$', waitForAsync(() => { + const _authenticated = oidcSecurityService.authenticated(); + + expect(authenticatedSpy).toHaveBeenCalledTimes(1); + })); + }); + + describe('checkSessionChanged$', () => { + it('calls checkSessionService.checkSessionChanged$', waitForAsync(() => { + const spy = spyOnProperty( + checkSessionService, + 'checkSessionChanged$' + ).and.returnValue(of(true)); + + oidcSecurityService.checkSessionChanged$.subscribe(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + })); + }); + + describe('stsCallback$', () => { + it('calls callbackService.stsCallback$', waitForAsync(() => { + const spy = spyOnProperty( + callbackService, + 'stsCallback$' + ).and.returnValue(of()); + + oidcSecurityService.stsCallback$.subscribe(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + })); + }); + + describe('preloadAuthWellKnownDocument', () => { + it('calls authWellKnownService.queryAndStoreAuthWellKnownEndPoints with config', waitForAsync(() => { + const config = { configId: 'configid1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn( + authWellKnownService, + 'queryAndStoreAuthWellKnownEndPoints' + ).and.returnValue(of({})); + + oidcSecurityService.preloadAuthWellKnownDocument().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('getConfigurations', () => { + it('is not of type observable', () => { + expect(oidcSecurityService.getConfigurations).not.toEqual( + jasmine.any(Observable) + ); + }); + + it('calls configurationProvider.getAllConfigurations', () => { + const spy = spyOn(configurationService, 'getAllConfigurations'); + + oidcSecurityService.getConfigurations(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getConfiguration', () => { + it('is not of type observable', () => { + expect(oidcSecurityService.getConfiguration).not.toEqual( + jasmine.any(Observable) + ); + }); + + it('calls configurationProvider.getOpenIDConfiguration with passed configId when configId is passed', () => { + const spy = spyOn(configurationService, 'getOpenIDConfiguration'); + + oidcSecurityService.getConfiguration('configId'); + + expect(spy).toHaveBeenCalledOnceWith('configId'); + }); + }); + + describe('getUserData', () => { + it('calls configurationProvider.getOpenIDConfiguration with config', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(userService, 'getUserDataFromStore').and.returnValue({ + some: 'thing', + }); + + oidcSecurityService.getUserData('configId').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + + it('returns userdata', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + spyOn(userService, 'getUserDataFromStore').and.returnValue({ + some: 'thing', + }); + + oidcSecurityService.getUserData('configId').subscribe((result) => { + expect(result).toEqual({ some: 'thing' }); + }); + })); + }); + + describe('checkAuth', () => { + it('calls checkAuthService.checkAuth() without url if none is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + + oidcSecurityService.checkAuth().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], undefined); + }); + })); + + it('calls checkAuthService.checkAuth() with url if one is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn(checkAuthService, 'checkAuth').and.returnValue( + of({} as LoginResponse) + ); + + oidcSecurityService.checkAuth('some-url').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], 'some-url'); + }); + })); + }); + + describe('checkAuthMultiple', () => { + it('calls checkAuthService.checkAuth() without url if none is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn(checkAuthService, 'checkAuthMultiple').and.returnValue( + of([{}] as LoginResponse[]) + ); + + oidcSecurityService.checkAuthMultiple().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith([config], undefined); + }); + })); + + it('calls checkAuthService.checkAuthMultiple() with url if one is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn(checkAuthService, 'checkAuthMultiple').and.returnValue( + of([{}] as LoginResponse[]) + ); + + oidcSecurityService.checkAuthMultiple('some-url').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith([config], 'some-url'); + }); + })); + }); + + describe('isAuthenticated()', () => { + it('calls authStateService.isAuthenticated with passed configId when configId is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(authStateService, 'isAuthenticated').and.returnValue( + true + ); + + oidcSecurityService.isAuthenticated().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('checkAuthIncludingServer', () => { + it('calls checkAuthService.checkAuthIncludingServer()', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn( + checkAuthService, + 'checkAuthIncludingServer' + ).and.returnValue(of({} as LoginResponse)); + + oidcSecurityService.checkAuthIncludingServer().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config]); + }); + })); + }); + + describe('getAccessToken', () => { + it('calls authStateService.getAccessToken()', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(authStateService, 'getAccessToken').and.returnValue(''); + + oidcSecurityService.getAccessToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('getIdToken', () => { + it('calls authStateService.getIdToken()', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(authStateService, 'getIdToken').and.returnValue(''); + + oidcSecurityService.getIdToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('getRefreshToken', () => { + it('calls authStateService.getRefreshToken()', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn(authStateService, 'getRefreshToken').and.returnValue( + '' + ); + + oidcSecurityService.getRefreshToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('getAuthenticationResult', () => { + it('calls authStateService.getAuthenticationResult()', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn( + authStateService, + 'getAuthenticationResult' + ).and.returnValue(null); + + oidcSecurityService.getAuthenticationResult().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('getPayloadFromIdToken', () => { + it('calls `authStateService.getIdToken` method, encode = false', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + spyOn(authStateService, 'getIdToken').and.returnValue('some-token'); + const spy = spyOn( + tokenHelperService, + 'getPayloadFromToken' + ).and.returnValue(null); + + oidcSecurityService.getPayloadFromIdToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-token', false, config); + }); + })); + + it('calls `authStateService.getIdToken` method, encode = true', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + spyOn(authStateService, 'getIdToken').and.returnValue('some-token'); + const spy = spyOn( + tokenHelperService, + 'getPayloadFromToken' + ).and.returnValue(null); + + oidcSecurityService.getPayloadFromIdToken(true).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-token', true, config); + }); + })); + }); + + describe('getPayloadFromAccessToken', () => { + it('calls `authStateService.getAccessToken` method, encode = false', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + spyOn(authStateService, 'getAccessToken').and.returnValue( + 'some-access-token' + ); + const spy = spyOn( + tokenHelperService, + 'getPayloadFromToken' + ).and.returnValue(null); + + oidcSecurityService.getPayloadFromAccessToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + 'some-access-token', + false, + config + ); + }); + })); + + it('calls `authStateService.getIdToken` method, encode = true', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + spyOn(authStateService, 'getAccessToken').and.returnValue( + 'some-access-token' + ); + const spy = spyOn( + tokenHelperService, + 'getPayloadFromToken' + ).and.returnValue(null); + + oidcSecurityService.getPayloadFromAccessToken(true).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('some-access-token', true, config); + }); + })); + }); + + describe('setState', () => { + it('calls flowsDataService.setAuthStateControl with param', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn(flowsDataService, 'setAuthStateControl'); + + oidcSecurityService.setState('anyString').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith('anyString', config); + }); + })); + }); + + describe('getState', () => { + it('calls flowsDataService.getAuthStateControl', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn(flowsDataService, 'getAuthStateControl'); + + oidcSecurityService.getState().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config); + }); + })); + }); + + describe('authorize', () => { + it('calls login service login', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn(loginService, 'login'); + + oidcSecurityService.authorize(); + + expect(spy).toHaveBeenCalledOnceWith(config, undefined); + })); + + it('calls login service login with authoptions', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn(loginService, 'login'); + + oidcSecurityService.authorize('configId', { + customParams: { some: 'param' }, + }); + + expect(spy).toHaveBeenCalledOnceWith(config, { + customParams: { some: 'param' }, + }); + })); + }); + + describe('authorizeWithPopUp', () => { + it('calls login service loginWithPopUp', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + const spy = spyOn(loginService, 'loginWithPopUp').and.callFake(() => + of({} as LoginResponse) + ); + + oidcSecurityService.authorizeWithPopUp().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + config, + [config], + undefined, + undefined + ); + }); + })); + }); + + describe('forceRefreshSession', () => { + it('calls refreshSessionService userForceRefreshSession with configId from config when none is passed', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + + const spy = spyOn( + refreshSessionService, + 'userForceRefreshSession' + ).and.returnValue(of({} as LoginResponse)); + + oidcSecurityService.forceRefreshSession().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], undefined); + }); + })); + }); + + describe('logoffAndRevokeTokens', () => { + it('calls logoffRevocationService.logoffAndRevokeTokens', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + const spy = spyOn( + logoffRevocationService, + 'logoffAndRevokeTokens' + ).and.returnValue(of(null)); + + oidcSecurityService.logoffAndRevokeTokens().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], undefined); + }); + })); + }); + + describe('logoff', () => { + it('calls logoffRevocationService.logoff', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + const spy = spyOn(logoffRevocationService, 'logoff').and.returnValue( + of(null) + ); + + oidcSecurityService.logoff().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, [config], undefined); + }); + })); + }); + + describe('logoffLocal', () => { + it('calls logoffRevocationService.logoffLocal', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + const spy = spyOn(logoffRevocationService, 'logoffLocal'); + + oidcSecurityService.logoffLocal(); + expect(spy).toHaveBeenCalledOnceWith(config, [config]); + })); + }); + + describe('logoffLocalMultiple', () => { + it('calls logoffRevocationService.logoffLocalMultiple', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfigurations').and.returnValue( + of({ allConfigs: [config], currentConfig: config }) + ); + const spy = spyOn(logoffRevocationService, 'logoffLocalMultiple'); + + oidcSecurityService.logoffLocalMultiple(); + expect(spy).toHaveBeenCalledOnceWith([config]); + })); + }); + + describe('revokeAccessToken', () => { + it('calls logoffRevocationService.revokeAccessToken', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn( + logoffRevocationService, + 'revokeAccessToken' + ).and.returnValue(of(null)); + + oidcSecurityService.revokeAccessToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, undefined); + }); + })); + + it('calls logoffRevocationService.revokeAccessToken with accesstoken', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn( + logoffRevocationService, + 'revokeAccessToken' + ).and.returnValue(of(null)); + + oidcSecurityService.revokeAccessToken('access_token').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, 'access_token'); + }); + })); + }); + + describe('revokeRefreshToken', () => { + it('calls logoffRevocationService.revokeRefreshToken', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn( + logoffRevocationService, + 'revokeRefreshToken' + ).and.returnValue(of(null)); + + oidcSecurityService.revokeRefreshToken().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, undefined); + }); + })); + + it('calls logoffRevocationService.revokeRefreshToken with refresh token', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + const spy = spyOn( + logoffRevocationService, + 'revokeRefreshToken' + ).and.returnValue(of(null)); + + oidcSecurityService.revokeRefreshToken('refresh_token').subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, 'refresh_token'); + }); + })); + }); + + describe('getEndSessionUrl', () => { + it('calls logoffRevocationService.getEndSessionUrl ', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(urlService, 'getEndSessionUrl').and.returnValue(null); + + oidcSecurityService.getEndSessionUrl().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, undefined); + }); + })); + + it('calls logoffRevocationService.getEndSessionUrl with customparams', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(urlService, 'getEndSessionUrl').and.returnValue(null); + + oidcSecurityService + .getEndSessionUrl({ custom: 'params' }) + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, { custom: 'params' }); + }); + })); + }); + + describe('getAuthorizeUrl', () => { + it('calls urlService.getAuthorizeUrl ', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(urlService, 'getAuthorizeUrl').and.returnValue( + of(null) + ); + + oidcSecurityService.getAuthorizeUrl().subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, undefined); + }); + })); + + it('calls urlService.getAuthorizeUrl with customparams', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(configurationService, 'getOpenIDConfiguration').and.returnValue( + of(config) + ); + + const spy = spyOn(urlService, 'getAuthorizeUrl').and.returnValue( + of(null) + ); + + oidcSecurityService + .getAuthorizeUrl({ custom: 'params' }) + .subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith(config, { + customParams: { custom: 'params' }, + }); + }); + })); + }); +}); diff --git a/src/oidc.security.service.ts b/src/oidc.security.service.ts new file mode 100644 index 0000000..e3eb186 --- /dev/null +++ b/src/oidc.security.service.ts @@ -0,0 +1,576 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable } from 'rxjs'; +import { concatMap, map } from 'rxjs/operators'; +import { AuthOptions, LogoutAuthOptions } from './auth-options'; +import { AuthenticatedResult } from './auth-state/auth-result'; +import { AuthStateService } from './auth-state/auth-state.service'; +import { CheckAuthService } from './auth-state/check-auth.service'; +import { CallbackService } from './callback/callback.service'; +import { RefreshSessionService } from './callback/refresh-session.service'; +import { AuthWellKnownEndpoints } from './config/auth-well-known/auth-well-known-endpoints'; +import { AuthWellKnownService } from './config/auth-well-known/auth-well-known.service'; +import { ConfigurationService } from './config/config.service'; +import { OpenIdConfiguration } from './config/openid-configuration'; +import { AuthResult } from './flows/callback-context'; +import { FlowsDataService } from './flows/flows-data.service'; +import { CheckSessionService } from './iframe/check-session.service'; +import { LoginResponse } from './login/login-response'; +import { LoginService } from './login/login.service'; +import { PopupOptions } from './login/popup/popup-options'; +import { LogoffRevocationService } from './logoff-revoke/logoff-revocation.service'; +import { UserService } from './user-data/user.service'; +import { UserDataResult } from './user-data/userdata-result'; +import { TokenHelperService } from './utils/tokenHelper/token-helper.service'; +import { UrlService } from './utils/url/url.service'; +import { toSignal } from 'injection-js/rxjs-interop'; + +@Injectable() +export class OidcSecurityService { + private readonly checkSessionService = inject(CheckSessionService); + + private readonly checkAuthService = inject(CheckAuthService); + + private readonly userService = inject(UserService); + + private readonly tokenHelperService = inject(TokenHelperService); + + private readonly configurationService = inject(ConfigurationService); + + private readonly authStateService = inject(AuthStateService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly callbackService = inject(CallbackService); + + private readonly logoffRevocationService = inject(LogoffRevocationService); + + private readonly loginService = inject(LoginService); + + private readonly refreshSessionService = inject(RefreshSessionService); + + private readonly urlService = inject(UrlService); + + private readonly authWellKnownService = inject(AuthWellKnownService); + + /** + * Provides information about the user after they have logged in. + * + * @returns Returns an object containing either the user data directly (single config) or + * the user data per config in case you are running with multiple configs + */ + get userData$(): Observable { + return this.userService.userData$; + } + + /** + * Provides information about the user after they have logged in. + * + * @returns Returns an object containing either the user data directly (single config) or + * the user data per config in case you are running with multiple configs + */ + userData = toSignal(this.userData$, { requireSync: true }); + + /** + * Emits each time an authorization event occurs. + * + * @returns Returns an object containing if you are authenticated or not. + * Single Config: true if config is authenticated, false if not. + * Multiple Configs: true is all configs are authenticated, false if only one of them is not + * + * The `allConfigsAuthenticated` property contains the auth information _per config_. + */ + get isAuthenticated$(): Observable { + return this.authStateService.authenticated$; + } + + /** + * Emits each time an authorization event occurs. + * + * @returns Returns an object containing if you are authenticated or not. + * Single Config: true if config is authenticated, false if not. + * Multiple Configs: true is all configs are authenticated, false if only one of them is not + * + * The `allConfigsAuthenticated` property contains the auth information _per config_. + */ + authenticated = toSignal(this.isAuthenticated$, { requireSync: true }); + + /** + * Emits each time the server sends a CheckSession event and the value changed. This property will always return + * true. + */ + get checkSessionChanged$(): Observable { + return this.checkSessionService.checkSessionChanged$; + } + + /** + * Emits on a Security Token Service callback. The observable will never contain a value. + */ + get stsCallback$(): Observable { + return this.callbackService.stsCallback$; + } + + preloadAuthWellKnownDocument( + configId?: string + ): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + concatMap((config) => + this.authWellKnownService.queryAndStoreAuthWellKnownEndPoints(config) + ) + ); + } + + /** + * Returns the currently active OpenID configurations. + * + * @returns an array of OpenIdConfigurations. + */ + getConfigurations(): OpenIdConfiguration[] { + return this.configurationService.getAllConfigurations(); + } + + /** + * Returns a single active OpenIdConfiguration. + * + * @param configId The configId to identify the config. If not passed, the first one is being returned + */ + getConfiguration(configId?: string): Observable { + return this.configurationService.getOpenIDConfiguration(configId); + } + + /** + * Returns the userData for a configuration + * + * @param configId The configId to identify the config. If not passed, the first one is being used + */ + getUserData(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.userService.getUserDataFromStore(config))); + } + + /** + * Starts the complete setup flow for one configuration. Calling will start the entire authentication flow, and the returned observable + * will denote whether the user was successfully authenticated including the user data, the access token, the configId and + * an error message in case an error happened + * + * @param url The URL to perform the authorization on the behalf of. + * @param configId The configId to perform the authorization on the behalf of. If not passed, the first configs will be taken + * + * @returns An object `LoginResponse` containing all information about the login + */ + checkAuth(url?: string, configId?: string): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.checkAuthService.checkAuth(currentConfig, allConfigs, url) + ) + ); + } + + /** + * Starts the complete setup flow for multiple configurations. + * Calling will start the entire authentication flow, and the returned observable + * will denote whether the user was successfully authenticated including the user data, the access token, the configId and + * an error message in case an error happened in an array for each config which was provided + * + * @param url The URL to perform the authorization on the behalf of. + * + * @returns An array of `LoginResponse` objects containing all information about the logins + */ + checkAuthMultiple(url?: string): Observable { + return this.configurationService + .getOpenIDConfigurations() + .pipe( + concatMap(({ allConfigs }) => + this.checkAuthService.checkAuthMultiple(allConfigs, url) + ) + ); + } + + /** + * Provides information about the current authenticated state + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns A boolean whether the config is authenticated or not. + */ + isAuthenticated(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.authStateService.isAuthenticated(config))); + } + + /** + * Checks the server for an authenticated session using the iframe silent renew if not locally authenticated. + */ + checkAuthIncludingServer(configId?: string): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.checkAuthService.checkAuthIncludingServer( + currentConfig, + allConfigs + ) + ) + ); + } + + /** + * Returns the access token for the login scenario. + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns A string with the access token. + */ + getAccessToken(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.authStateService.getAccessToken(config))); + } + + /** + * Returns the ID token for the sign-in. + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns A string with the id token. + */ + getIdToken(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.authStateService.getIdToken(config))); + } + + /** + * Returns the refresh token, if present, for the sign-in. + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns A string with the refresh token. + */ + getRefreshToken(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.authStateService.getRefreshToken(config))); + } + + /** + * Returns the authentication result, if present, for the sign-in. + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns A object with the authentication result + */ + getAuthenticationResult(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + map((config) => this.authStateService.getAuthenticationResult(config)) + ); + } + + /** + * Returns the payload from the ID token. + * + * @param encode Set to true if the payload is base64 encoded + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns The payload from the id token. + */ + getPayloadFromIdToken(encode = false, configId?: string): Observable { + return this.configurationService.getOpenIDConfiguration(configId).pipe( + map((config) => { + const token = this.authStateService.getIdToken(config); + + return this.tokenHelperService.getPayloadFromToken( + token, + encode, + config + ); + }) + ); + } + + /** + * Returns the payload from the access token. + * + * @param encode Set to true if the payload is base64 encoded + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns The payload from the access token. + */ + getPayloadFromAccessToken( + encode = false, + configId?: string + ): Observable { + return this.configurationService.getOpenIDConfiguration(configId).pipe( + map((config) => { + const token = this.authStateService.getAccessToken(config); + + return this.tokenHelperService.getPayloadFromToken( + token, + encode, + config + ); + }) + ); + } + + /** + * Sets a custom state for the authorize request. + * + * @param state The state to set. + * @param configId The configId to check the information for. If not passed, the first configs will be taken + */ + setState(state: string, configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + map((config) => + this.flowsDataService.setAuthStateControl(state, config) + ) + ); + } + + /** + * Gets the state value used for the authorize request. + * + * @param configId The configId to check the information for. If not passed, the first configs will be taken + * + * @returns The state value used for the authorize request. + */ + getState(configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe(map((config) => this.flowsDataService.getAuthStateControl(config))); + } + + /** + * Redirects the user to the Security Token Service to begin the authentication process. + * + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * @param authOptions The custom options for the the authentication request. + */ + authorize(configId?: string, authOptions?: AuthOptions): void { + this.configurationService + .getOpenIDConfiguration(configId) + .subscribe((config) => this.loginService.login(config, authOptions)); + } + + /** + * Opens the Security Token Service in a new window to begin the authentication process. + * + * @param authOptions The custom options for the authentication request. + * @param popupOptions The configuration for the popup window. + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns An `Observable` containing all information about the login + */ + authorizeWithPopUp( + authOptions?: AuthOptions, + popupOptions?: PopupOptions, + configId?: string + ): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.loginService.loginWithPopUp( + currentConfig, + allConfigs, + authOptions, + popupOptions + ) + ) + ); + } + + /** + * Manually refreshes the session. + * + * @param customParams Custom parameters to pass to the refresh request. + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns An `Observable` containing all information about the login + */ + forceRefreshSession( + customParams?: { [key: string]: string | number | boolean }, + configId?: string + ): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.refreshSessionService.userForceRefreshSession( + currentConfig, + allConfigs, + customParams + ) + ) + ); + } + + /** + * Revokes the refresh token (if present) and the access token on the server and then performs the logoff operation. + * The refresh token and and the access token are revoked on the server. If the refresh token does not exist + * only the access token is revoked. Then the logout run. + * + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * @param logoutAuthOptions The custom options for the request. + * + * @returns An observable when the action is finished + */ + logoffAndRevokeTokens( + configId?: string, + logoutAuthOptions?: LogoutAuthOptions + ): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.logoffRevocationService.logoffAndRevokeTokens( + currentConfig, + allConfigs, + logoutAuthOptions + ) + ) + ); + } + + /** + * Logs out on the server and the local client. If the server state has changed, confirmed via check session, + * then only a local logout is performed. + * + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * @param logoutAuthOptions with custom parameters and/or an custom url handler + */ + logoff( + configId?: string, + logoutAuthOptions?: LogoutAuthOptions + ): Observable { + return this.configurationService + .getOpenIDConfigurations(configId) + .pipe( + concatMap(({ allConfigs, currentConfig }) => + this.logoffRevocationService.logoff( + currentConfig, + allConfigs, + logoutAuthOptions + ) + ) + ); + } + + /** + * Logs the user out of the application without logging them out of the server. + * Use this method if you have _one_ config enabled. + * + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + */ + logoffLocal(configId?: string): void { + this.configurationService + .getOpenIDConfigurations(configId) + .subscribe(({ allConfigs, currentConfig }) => + this.logoffRevocationService.logoffLocal(currentConfig, allConfigs) + ); + } + + /** + * Logs the user out of the application for all configs without logging them out of the server. + * Use this method if you have _multiple_ configs enabled. + */ + logoffLocalMultiple(): void { + this.configurationService + .getOpenIDConfigurations() + .subscribe(({ allConfigs }) => + this.logoffRevocationService.logoffLocalMultiple(allConfigs) + ); + } + + /** + * Revokes an access token on the Security Token Service. This is only required in the code flow with refresh tokens. If no token is + * provided, then the token from the storage is revoked. You can pass any token to revoke. + * https://tools.ietf.org/html/rfc7009 + * + * @param accessToken The access token to revoke. + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns An observable when the action is finished + */ + revokeAccessToken(accessToken?: any, configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + concatMap((config) => + this.logoffRevocationService.revokeAccessToken(config, accessToken) + ) + ); + } + + /** + * Revokes a refresh token on the Security Token Service. This is only required in the code flow with refresh tokens. If no token is + * provided, then the token from the storage is revoked. You can pass any token to revoke. + * https://tools.ietf.org/html/rfc7009 + * + * @param refreshToken The access token to revoke. + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns An observable when the action is finished + */ + revokeRefreshToken(refreshToken?: any, configId?: string): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + concatMap((config) => + this.logoffRevocationService.revokeRefreshToken(config, refreshToken) + ) + ); + } + + /** + * Creates the end session URL which can be used to implement an alternate server logout. + * + * @param customParams + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns A string with the end session url or null + */ + getEndSessionUrl( + customParams?: { [p: string]: string | number | boolean }, + configId?: string + ): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + map((config) => this.urlService.getEndSessionUrl(config, customParams)) + ); + } + + /** + * Creates the authorize URL based on your flow + * + * @param customParams + * @param configId The configId to perform the action in behalf of. If not passed, the first configs will be taken + * + * @returns A string with the authorize URL or null + */ + getAuthorizeUrl( + customParams?: { [p: string]: string | number | boolean }, + configId?: string + ): Observable { + return this.configurationService + .getOpenIDConfiguration(configId) + .pipe( + concatMap((config) => + this.urlService.getAuthorizeUrl( + config, + customParams ? { customParams } : undefined + ) + ) + ); + } +} diff --git a/src/provide-auth.spec.ts b/src/provide-auth.spec.ts new file mode 100644 index 0000000..af0a4e0 --- /dev/null +++ b/src/provide-auth.spec.ts @@ -0,0 +1,95 @@ +import { APP_INITIALIZER } from '@angular/core'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../test/auto-mock'; +import { PASSED_CONFIG } from './auth-config'; +import { ConfigurationService } from './config/config.service'; +import { + StsConfigHttpLoader, + StsConfigLoader, + StsConfigStaticLoader, +} from './config/loader/config-loader'; +import { OidcSecurityService } from './oidc.security.service'; +import { provideAuth, withAppInitializerAuthCheck } from './provide-auth'; + +describe('provideAuth', () => { + describe('APP_CONFIG', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + provideAuth({ config: { authority: 'something' } }), + mockProvider(ConfigurationService), + ], + }).compileComponents(); + })); + + it('should provide config', () => { + const config = TestBed.inject(PASSED_CONFIG); + + expect(config).toEqual({ config: { authority: 'something' } }); + }); + + it('should create StsConfigStaticLoader if config is passed', () => { + const configLoader = TestBed.inject(StsConfigLoader); + + expect(configLoader instanceof StsConfigStaticLoader).toBe(true); + }); + }); + + describe('StsConfigHttpLoader', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + provideAuth({ + loader: { + provide: StsConfigLoader, + useFactory: () => new StsConfigHttpLoader(of({})), + }, + }), + mockProvider(ConfigurationService), + ], + }).compileComponents(); + })); + + it('should create StsConfigStaticLoader if config is passed', () => { + const configLoader = TestBed.inject(StsConfigLoader); + + expect(configLoader instanceof StsConfigHttpLoader).toBe(true); + }); + }); + + describe('features', () => { + let oidcSecurityServiceMock: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + oidcSecurityServiceMock = jasmine.createSpyObj( + 'OidcSecurityService', + ['checkAuthMultiple'] + ); + TestBed.configureTestingModule({ + providers: [ + provideAuth( + { config: { authority: 'something' } }, + withAppInitializerAuthCheck() + ), + mockProvider(ConfigurationService), + { + provide: OidcSecurityService, + useValue: oidcSecurityServiceMock, + }, + ], + }).compileComponents(); + })); + + it('should provide APP_INITIALIZER config', () => { + const config = TestBed.inject(APP_INITIALIZER); + + expect(config.length) + .withContext('Expected an APP_INITIALIZER to be registered') + .toBe(1); + expect(oidcSecurityServiceMock.checkAuthMultiple).toHaveBeenCalledTimes( + 1 + ); + }); + }); +}); diff --git a/src/provide-auth.ts b/src/provide-auth.ts new file mode 100644 index 0000000..1730034 --- /dev/null +++ b/src/provide-auth.ts @@ -0,0 +1,79 @@ +import { + APP_INITIALIZER, + EnvironmentProviders, + makeEnvironmentProviders, + Provider, +} from 'injection-js'; +import { + createStaticLoader, + PASSED_CONFIG, + PassedInitialConfig, +} from './auth-config'; +import { StsConfigLoader } from './config/loader/config-loader'; +import { AbstractLoggerService } from './logging/abstract-logger.service'; +import { ConsoleLoggerService } from './logging/console-logger.service'; +import { OidcSecurityService } from './oidc.security.service'; +import { AbstractSecurityStorage } from './storage/abstract-security-storage'; +import { DefaultSessionStorageService } from './storage/default-sessionstorage.service'; + +/** + * A feature to be used with `provideAuth`. + */ +export interface AuthFeature { + ɵproviders: Provider[]; +} + +export function provideAuth( + passedConfig: PassedInitialConfig, + ...features: AuthFeature[] +): EnvironmentProviders { + const providers = _provideAuth(passedConfig); + + for (const feature of features) { + providers.push(...feature.ɵproviders); + } + + return makeEnvironmentProviders(providers); +} + +export function _provideAuth(passedConfig: PassedInitialConfig): Provider[] { + return [ + // Make the PASSED_CONFIG available through injection + { provide: PASSED_CONFIG, useValue: passedConfig }, + + // Create the loader: Either the one getting passed or a static one + passedConfig?.loader || { + provide: StsConfigLoader, + useFactory: createStaticLoader, + deps: [PASSED_CONFIG], + }, + { + provide: AbstractSecurityStorage, + useClass: DefaultSessionStorageService, + }, + { provide: AbstractLoggerService, useClass: ConsoleLoggerService }, + ]; +} + +/** + * Configures an app initializer, which is called before the app starts, and + * resolves any OAuth callback variables. + * When used, it replaces the need to manually call + * `OidcSecurityService.checkAuth(...)` or + * `OidcSecurityService.checkAuthMultiple(...)`. + * + * @see https://angular.dev/api/core/APP_INITIALIZER + */ +export function withAppInitializerAuthCheck(): AuthFeature { + return { + ɵproviders: [ + { + provide: APP_INITIALIZER, + useFactory: (oidcSecurityService: OidcSecurityService) => () => + oidcSecurityService.checkAuthMultiple(), + multi: true, + deps: [OidcSecurityService], + }, + ], + }; +} diff --git a/src/public-api.ts b/src/public-api.ts new file mode 100644 index 0000000..f6c1af3 --- /dev/null +++ b/src/public-api.ts @@ -0,0 +1,5 @@ +/* + * Public API Surface of angular-auth-oidc-client + */ + +export * from '.'; diff --git a/src/public-events/event-types.ts b/src/public-events/event-types.ts new file mode 100644 index 0000000..54f35a9 --- /dev/null +++ b/src/public-events/event-types.ts @@ -0,0 +1,17 @@ +export enum EventTypes { + /** + * This only works in the AppModule Constructor + */ + ConfigLoaded, + CheckingAuth, + CheckingAuthFinished, + CheckingAuthFinishedWithError, + ConfigLoadingFailed, + CheckSessionReceived, + UserDataChanged, + NewAuthenticationResult, + TokenExpired, + IdTokenExpired, + SilentRenewStarted, + SilentRenewFailed, +} diff --git a/src/public-events/notification.ts b/src/public-events/notification.ts new file mode 100644 index 0000000..ec9d0ec --- /dev/null +++ b/src/public-events/notification.ts @@ -0,0 +1,6 @@ +import { EventTypes } from './event-types'; + +export interface OidcClientNotification { + type: EventTypes; + value?: T; +} diff --git a/src/public-events/public-events.service.spec.ts b/src/public-events/public-events.service.spec.ts new file mode 100644 index 0000000..dcc5a78 --- /dev/null +++ b/src/public-events/public-events.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { filter } from 'rxjs/operators'; +import { EventTypes } from './event-types'; +import { PublicEventsService } from './public-events.service'; + +describe('Events Service', () => { + let eventsService: PublicEventsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [PublicEventsService], + }); + }); + + beforeEach(() => { + eventsService = TestBed.inject(PublicEventsService); + }); + + it('should create', () => { + expect(eventsService).toBeTruthy(); + }); + + it('registering to single event with one event emit works', waitForAsync(() => { + eventsService.registerForEvents().subscribe((firedEvent) => { + expect(firedEvent).toBeTruthy(); + expect(firedEvent).toEqual({ + type: EventTypes.ConfigLoaded, + value: { myKey: 'myValue' }, + }); + }); + eventsService.fireEvent(EventTypes.ConfigLoaded, { myKey: 'myValue' }); + })); + + it('registering to single event with multiple same event emit works', waitForAsync(() => { + const spy = jasmine.createSpy('spy'); + + eventsService.registerForEvents().subscribe((firedEvent) => { + spy(firedEvent); + expect(firedEvent).toBeTruthy(); + }); + eventsService.fireEvent(EventTypes.ConfigLoaded, { myKey: 'myValue' }); + eventsService.fireEvent(EventTypes.ConfigLoaded, { myKey: 'myValue2' }); + + expect(spy.calls.count()).toBe(2); + expect(spy.calls.first().args[0]).toEqual({ + type: EventTypes.ConfigLoaded, + value: { myKey: 'myValue' }, + }); + expect(spy.calls.mostRecent().args[0]).toEqual({ + type: EventTypes.ConfigLoaded, + value: { myKey: 'myValue2' }, + }); + })); + + it('registering to single event with multiple emit works', waitForAsync(() => { + eventsService + .registerForEvents() + .pipe(filter((x) => x.type === EventTypes.ConfigLoaded)) + .subscribe((firedEvent) => { + expect(firedEvent).toBeTruthy(); + expect(firedEvent).toEqual({ + type: EventTypes.ConfigLoaded, + value: { myKey: 'myValue' }, + }); + }); + eventsService.fireEvent(EventTypes.ConfigLoaded, { myKey: 'myValue' }); + eventsService.fireEvent(EventTypes.NewAuthenticationResult, true); + })); +}); diff --git a/src/public-events/public-events.service.ts b/src/public-events/public-events.service.ts new file mode 100644 index 0000000..030edec --- /dev/null +++ b/src/public-events/public-events.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from 'injection-js'; +import { Observable, ReplaySubject } from 'rxjs'; +import { EventTypes } from './event-types'; +import { OidcClientNotification } from './notification'; + +@Injectable() +export class PublicEventsService { + private readonly notify = new ReplaySubject>(1); + + /** + * Fires a new event. + * + * @param type The event type. + * @param value The event value. + */ + fireEvent(type: EventTypes, value?: T): void { + this.notify.next({ type, value }); + } + + /** + * Wires up the event notification observable. + */ + registerForEvents(): Observable> { + return this.notify.asObservable(); + } +} diff --git a/src/storage/abstract-security-storage.ts b/src/storage/abstract-security-storage.ts new file mode 100644 index 0000000..f2efeb6 --- /dev/null +++ b/src/storage/abstract-security-storage.ts @@ -0,0 +1,34 @@ +import { Injectable } from 'injection-js'; + +/** + * Implement this class-interface to create a custom storage. + */ +@Injectable() +export abstract class AbstractSecurityStorage { + /** + * This method must contain the logic to read the storage. + * + * @return The value of the given key + */ + abstract read(key: string): string | null; + + /** + * This method must contain the logic to write the storage. + * + * @param key The key to write a value for + * @param value The value for the given key + */ + abstract write(key: string, value: string): void; + + /** + * This method must contain the logic to remove an item from the storage. + * + * @param key The value for the key to be removed + */ + abstract remove(key: string): void; + + /** + * This method must contain the logic to remove all items from the storage. + */ + abstract clear(): void; +} diff --git a/src/storage/browser-storage.service.spec.ts b/src/storage/browser-storage.service.spec.ts new file mode 100644 index 0000000..e40c86e --- /dev/null +++ b/src/storage/browser-storage.service.spec.ts @@ -0,0 +1,168 @@ +import { TestBed } from '@angular/core/testing'; +import { mockClass, mockProvider } from '../../test/auto-mock'; +import { LoggerService } from '../logging/logger.service'; +import { AbstractSecurityStorage } from './abstract-security-storage'; +import { BrowserStorageService } from './browser-storage.service'; +import { DefaultSessionStorageService } from './default-sessionstorage.service'; + +describe('BrowserStorageService', () => { + let service: BrowserStorageService; + let abstractSecurityStorage: AbstractSecurityStorage; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(LoggerService), + { + provide: AbstractSecurityStorage, + useClass: mockClass(DefaultSessionStorageService), + }, + ], + }); + }); + + beforeEach(() => { + abstractSecurityStorage = TestBed.inject(AbstractSecurityStorage); + service = TestBed.inject(BrowserStorageService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('read', () => { + it('returns null if there is no storage', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(false); + + expect(service.read('anything', config)).toBeNull(); + }); + + it('returns null if getItem returns null', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(true); + + const result = service.read('anything', config); + + expect(result).toBeNull(); + }); + + it('returns the item if getItem returns an item', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(true); + const returnValue = `{ "name":"John", "age":30, "city":"New York"}`; + + spyOn(abstractSecurityStorage, 'read').and.returnValue(returnValue); + const result = service.read('anything', config); + + expect(result).toEqual(JSON.parse(returnValue)); + }); + }); + + describe('write', () => { + it('returns false if there is no storage', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(false); + + expect(service.write('anyvalue', config)).toBeFalse(); + }); + + it('writes object correctly with configId', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(true); + const writeSpy = spyOn( + abstractSecurityStorage, + 'write' + ).and.callThrough(); + + const result = service.write({ anyKey: 'anyvalue' }, config); + + expect(result).toBe(true); + expect(writeSpy).toHaveBeenCalledOnceWith( + 'configId1', + JSON.stringify({ anyKey: 'anyvalue' }) + ); + }); + + it('writes null if item is falsy', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(true); + + const writeSpy = spyOn( + abstractSecurityStorage, + 'write' + ).and.callThrough(); + const somethingFalsy = ''; + + const result = service.write(somethingFalsy, config); + + expect(result).toBe(true); + expect(writeSpy).toHaveBeenCalledOnceWith( + 'configId1', + JSON.stringify(null) + ); + }); + }); + + describe('remove', () => { + it('returns false if there is no storage', () => { + const config = { configId: 'configId1' }; + + spyOn(service as any, 'hasStorage').and.returnValue(false); + expect(service.remove('anything', config)).toBeFalse(); + }); + + it('returns true if removeItem is called', () => { + spyOn(service as any, 'hasStorage').and.returnValue(true); + const config = { configId: 'configId1' }; + + const setItemSpy = spyOn( + abstractSecurityStorage, + 'remove' + ).and.callThrough(); + + const result = service.remove('anyKey', config); + + expect(result).toBe(true); + expect(setItemSpy).toHaveBeenCalledOnceWith('anyKey'); + }); + }); + + describe('clear', () => { + it('returns false if there is no storage', () => { + spyOn(service as any, 'hasStorage').and.returnValue(false); + const config = { configId: 'configId1' }; + + expect(service.clear(config)).toBeFalse(); + }); + + it('returns true if clear is called', () => { + spyOn(service as any, 'hasStorage').and.returnValue(true); + + const setItemSpy = spyOn( + abstractSecurityStorage, + 'clear' + ).and.callThrough(); + const config = { configId: 'configId1' }; + + const result = service.clear(config); + + expect(result).toBe(true); + expect(setItemSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('hasStorage', () => { + it('returns false if there is no storage', () => { + (Storage as any) = undefined; + expect((service as any).hasStorage()).toBeFalse(); + Storage = Storage; + }); + }); +}); diff --git a/src/storage/browser-storage.service.ts b/src/storage/browser-storage.service.ts new file mode 100644 index 0000000..e8afdb9 --- /dev/null +++ b/src/storage/browser-storage.service.ts @@ -0,0 +1,118 @@ +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { AbstractSecurityStorage } from './abstract-security-storage'; + +@Injectable() +export class BrowserStorageService { + private readonly loggerService = inject(LoggerService); + + private readonly abstractSecurityStorage = inject(AbstractSecurityStorage); + + read(key: string, configuration: OpenIdConfiguration): any { + const { configId } = configuration; + + if (!configId) { + this.loggerService.logDebug( + configuration, + `Wanted to read '${key}' but configId was '${configId}'` + ); + + return null; + } + + if (!this.hasStorage()) { + this.loggerService.logDebug( + configuration, + `Wanted to read '${key}' but Storage was undefined` + ); + + return null; + } + + const storedConfig = this.abstractSecurityStorage.read(configId); + + if (!storedConfig) { + return null; + } + + return JSON.parse(storedConfig); + } + + write(value: any, configuration: OpenIdConfiguration): boolean { + const { configId } = configuration; + + if (!configId) { + this.loggerService.logDebug( + configuration, + `Wanted to write but configId was '${configId}'` + ); + + return false; + } + + if (!this.hasStorage()) { + this.loggerService.logDebug( + configuration, + `Wanted to write but Storage was falsy` + ); + + return false; + } + + value = value || null; + + this.abstractSecurityStorage.write(configId, JSON.stringify(value)); + + return true; + } + + remove(key: string, configuration: OpenIdConfiguration): boolean { + if (!this.hasStorage()) { + this.loggerService.logDebug( + configuration, + `Wanted to remove '${key}' but Storage was falsy` + ); + + return false; + } + + // const storage = this.getStorage(configuration); + // if (!storage) { + // this.loggerService.logDebug(configuration, `Wanted to write '${key}' but Storage was falsy`); + + // return false; + // } + + this.abstractSecurityStorage.remove(key); + + return true; + } + + // TODO THIS STORAGE WANTS AN ID BUT CLEARS EVERYTHING + clear(configuration: OpenIdConfiguration): boolean { + if (!this.hasStorage()) { + this.loggerService.logDebug( + configuration, + `Wanted to clear storage but Storage was falsy` + ); + + return false; + } + + // const storage = this.getStorage(configuration); + // if (!storage) { + // this.loggerService.logDebug(configuration, `Wanted to clear storage but Storage was falsy`); + + // return false; + // } + + this.abstractSecurityStorage.clear(); + + return true; + } + + private hasStorage(): boolean { + return typeof Storage !== 'undefined'; + } +} diff --git a/src/storage/default-localstorage.service.spec.ts b/src/storage/default-localstorage.service.spec.ts new file mode 100644 index 0000000..319d609 --- /dev/null +++ b/src/storage/default-localstorage.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { DefaultLocalStorageService } from './default-localstorage.service'; + +describe('DefaultLocalStorageService', () => { + let service: DefaultLocalStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultLocalStorageService], + }); + }); + + beforeEach(() => { + service = TestBed.inject(DefaultLocalStorageService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('read', () => { + it('should call localstorage.getItem', () => { + const spy = spyOn(localStorage, 'getItem'); + + service.read('henlo'); + + expect(spy).toHaveBeenCalledOnceWith('henlo'); + }); + }); + + describe('write', () => { + it('should call localstorage.setItem', () => { + const spy = spyOn(localStorage, 'setItem'); + + service.write('henlo', 'furiend'); + + expect(spy).toHaveBeenCalledOnceWith('henlo', 'furiend'); + }); + }); + + describe('remove', () => { + it('should call localstorage.removeItem', () => { + const spy = spyOn(localStorage, 'removeItem'); + + service.remove('henlo'); + + expect(spy).toHaveBeenCalledOnceWith('henlo'); + }); + }); + + describe('clear', () => { + it('should call localstorage.clear', () => { + const spy = spyOn(localStorage, 'clear'); + + service.clear(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/storage/default-localstorage.service.ts b/src/storage/default-localstorage.service.ts new file mode 100644 index 0000000..2fc9e82 --- /dev/null +++ b/src/storage/default-localstorage.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from 'injection-js'; +import { AbstractSecurityStorage } from './abstract-security-storage'; + +@Injectable() +export class DefaultLocalStorageService implements AbstractSecurityStorage { + public read(key: string): string | null { + return localStorage.getItem(key); + } + + public write(key: string, value: string): void { + localStorage.setItem(key, value); + } + + public remove(key: string): void { + localStorage.removeItem(key); + } + + public clear(): void { + localStorage.clear(); + } +} diff --git a/src/storage/default-sessionstorage.service.spec.ts b/src/storage/default-sessionstorage.service.spec.ts new file mode 100644 index 0000000..73f6cb6 --- /dev/null +++ b/src/storage/default-sessionstorage.service.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { DefaultSessionStorageService } from './default-sessionstorage.service'; + +describe('DefaultSessionStorageService', () => { + let service: DefaultSessionStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultSessionStorageService], + }); + }); + + beforeEach(() => { + service = TestBed.inject(DefaultSessionStorageService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('read', () => { + it('should call sessionstorage.getItem', () => { + const spy = spyOn(sessionStorage, 'getItem'); + + service.read('henlo'); + + expect(spy).toHaveBeenCalledOnceWith('henlo'); + }); + }); + + describe('write', () => { + it('should call sessionstorage.setItem', () => { + const spy = spyOn(sessionStorage, 'setItem'); + + service.write('henlo', 'furiend'); + + expect(spy).toHaveBeenCalledOnceWith('henlo', 'furiend'); + }); + }); + + describe('remove', () => { + it('should call sessionstorage.removeItem', () => { + const spy = spyOn(sessionStorage, 'removeItem'); + + service.remove('henlo'); + + expect(spy).toHaveBeenCalledOnceWith('henlo'); + }); + }); + + describe('clear', () => { + it('should call sessionstorage.clear', () => { + const spy = spyOn(sessionStorage, 'clear'); + + service.clear(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/storage/default-sessionstorage.service.ts b/src/storage/default-sessionstorage.service.ts new file mode 100644 index 0000000..93dbbc5 --- /dev/null +++ b/src/storage/default-sessionstorage.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from 'injection-js'; +import { AbstractSecurityStorage } from './abstract-security-storage'; + +@Injectable() +export class DefaultSessionStorageService implements AbstractSecurityStorage { + public read(key: string): string | null { + return sessionStorage.getItem(key); + } + + public write(key: string, value: string): void { + sessionStorage.setItem(key, value); + } + + public remove(key: string): void { + sessionStorage.removeItem(key); + } + + public clear(): void { + sessionStorage.clear(); + } +} diff --git a/src/storage/storage-persistence.service.spec.ts b/src/storage/storage-persistence.service.spec.ts new file mode 100644 index 0000000..204fee1 --- /dev/null +++ b/src/storage/storage-persistence.service.spec.ts @@ -0,0 +1,258 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../test/auto-mock'; +import { BrowserStorageService } from './browser-storage.service'; +import { StoragePersistenceService } from './storage-persistence.service'; + +describe('Storage Persistence Service', () => { + let service: StoragePersistenceService; + let securityStorage: BrowserStorageService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [mockProvider(BrowserStorageService)], + }); + }); + + beforeEach(() => { + service = TestBed.inject(StoragePersistenceService); + securityStorage = TestBed.inject(BrowserStorageService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('read', () => { + it('reads from oidcSecurityStorage with configId', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(securityStorage, 'read'); + + service.read('authNonce', config); + expect(spy).toHaveBeenCalledOnceWith('authNonce', config); + }); + + it('returns undefined (not throws exception) if key to read is not present on config', () => { + const config = { configId: 'configId1' }; + + spyOn(securityStorage, 'read').and.returnValue({ some: 'thing' }); + const result = service.read('authNonce', config); + + expect(result).toBeUndefined(); + }); + }); + + describe('write', () => { + it('writes to oidcSecurityStorage with correct key and correct config', () => { + const config = { configId: 'configId1' }; + const readSpy = spyOn(securityStorage, 'read'); + const writeSpy = spyOn(securityStorage, 'write'); + + service.write('authNonce', 'anyValue', config); + + expect(readSpy).toHaveBeenCalledOnceWith('authNonce', config); + expect(writeSpy).toHaveBeenCalledOnceWith( + { authNonce: 'anyValue' }, + config + ); + }); + }); + + describe('remove', () => { + it('should remove key from config', () => { + const config = { configId: 'configId1' }; + const readSpy = spyOn(securityStorage, 'read').and.returnValue({ + authNonce: 'anyValue', + }); + const writeSpy = spyOn(securityStorage, 'write'); + + service.remove('authNonce', config); + + expect(readSpy).toHaveBeenCalledOnceWith('authNonce', config); + expect(writeSpy).toHaveBeenCalledOnceWith({}, config); + }); + + it('does not crash when read with configId returns null', () => { + const config = { configId: 'configId1' }; + const readSpy = spyOn(securityStorage, 'read').and.returnValue(null); + const writeSpy = spyOn(securityStorage, 'write'); + + service.remove('authNonce', config); + + expect(readSpy).toHaveBeenCalledOnceWith('authNonce', config); + expect(writeSpy).toHaveBeenCalledOnceWith({}, config); + }); + }); + + describe('clear', () => { + it('should call oidcSecurityStorage.clear()', () => { + const clearSpy = spyOn(securityStorage, 'clear'); + + service.clear({}); + + expect(clearSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('resetStorageFlowData', () => { + it('resets the correct values', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(service, 'remove'); + + service.resetStorageFlowData(config); + + expect(spy).toHaveBeenCalledTimes(10); + expect(spy.calls.argsFor(0)).toEqual(['session_state', config]); + expect(spy.calls.argsFor(1)).toEqual([ + 'storageSilentRenewRunning', + config, + ]); + expect(spy.calls.argsFor(2)).toEqual([ + 'storageCodeFlowInProgress', + config, + ]); + expect(spy.calls.argsFor(3)).toEqual(['codeVerifier', config]); + expect(spy.calls.argsFor(4)).toEqual(['userData', config]); + expect(spy.calls.argsFor(5)).toEqual([ + 'storageCustomParamsAuthRequest', + config, + ]); + expect(spy.calls.argsFor(6)).toEqual(['access_token_expires_at', config]); + expect(spy.calls.argsFor(7)).toEqual([ + 'storageCustomParamsRefresh', + config, + ]); + expect(spy.calls.argsFor(8)).toEqual([ + 'storageCustomParamsEndSession', + config, + ]); + expect(spy.calls.argsFor(9)).toEqual(['reusable_refresh_token', config]); + }); + }); + + describe('resetAuthStateInStorage', () => { + it('resets the correct values', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(service, 'remove'); + + service.resetAuthStateInStorage(config); + + expect(spy.calls.argsFor(0)).toEqual(['authzData', config]); + expect(spy.calls.argsFor(1)).toEqual(['reusable_refresh_token', config]); + expect(spy.calls.argsFor(2)).toEqual(['authnResult', config]); + }); + }); + + describe('getAccessToken', () => { + it('get calls oidcSecurityStorage.read with correct key and returns the value', () => { + const returnValue = { authzData: 'someValue' }; + const config = { configId: 'configId1' }; + const spy = spyOn(securityStorage, 'read').and.returnValue(returnValue); + const result = service.getAccessToken(config); + + expect(result).toBe('someValue'); + expect(spy).toHaveBeenCalledOnceWith('authzData', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns null', () => { + const spy = spyOn(securityStorage, 'read').and.returnValue(null); + const config = { configId: 'configId1' }; + const result = service.getAccessToken(config); + + expect(result).toBeFalsy(); + expect(spy).toHaveBeenCalledOnceWith('authzData', config); + }); + }); + + describe('getIdToken', () => { + it('get calls oidcSecurityStorage.read with correct key and returns the value', () => { + const returnValue = { authnResult: { id_token: 'someValue' } }; + const spy = spyOn(securityStorage, 'read').and.returnValue(returnValue); + const config = { configId: 'configId1' }; + const result = service.getIdToken(config); + + expect(result).toBe('someValue'); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns null', () => { + const spy = spyOn(securityStorage, 'read').and.returnValue(null); + const config = { configId: 'configId1' }; + const result = service.getIdToken(config); + + expect(result).toBeFalsy(); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + }); + + describe('getAuthenticationResult', () => { + it('get calls oidcSecurityStorage.read with correct key and returns the value', () => { + const returnValue = { authnResult: { id_token: 'someValue' } }; + const config = { configId: 'configId1' }; + const spy = spyOn(securityStorage, 'read').and.returnValue(returnValue); + const result = service.getAuthenticationResult(config); + + expect(result.id_token).toBe('someValue'); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns null', () => { + const spy = spyOn(securityStorage, 'read').and.returnValue(null); + const config = { configId: 'configId1' }; + const result = service.getAuthenticationResult(config); + + expect(result).toBeFalsy(); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + }); + + describe('getRefreshToken', () => { + it('get calls oidcSecurityStorage.read with correct key and returns the value (refresh token with mandatory rotation - default)', () => { + const returnValue = { authnResult: { refresh_token: 'someValue' } }; + const spy = spyOn(securityStorage, 'read').and.returnValue(returnValue); + const config = { configId: 'configId1' }; + const result = service.getRefreshToken(config); + + expect(result).toBe('someValue'); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns the value (refresh token without rotation)', () => { + const returnValue = { reusable_refresh_token: 'test_refresh_token' }; + const config = { + configId: 'configId1', + allowUnsafeReuseRefreshToken: true, + }; + const spy = spyOn(securityStorage, 'read'); + + spy + .withArgs('reusable_refresh_token', config) + .and.returnValue(returnValue); + spy.withArgs('authnResult', config).and.returnValue(undefined); + const result = service.getRefreshToken(config); + + expect(result).toBe(returnValue.reusable_refresh_token); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith('authnResult', config); + expect(spy).toHaveBeenCalledWith('reusable_refresh_token', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns null', () => { + const returnValue = { authnResult: { NO_refresh_token: 'someValue' } }; + const spy = spyOn(securityStorage, 'read').and.returnValue(returnValue); + const config = { configId: 'configId1' }; + const result = service.getRefreshToken(config); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + + it('get calls oidcSecurityStorage.read with correct key and returns null', () => { + const spy = spyOn(securityStorage, 'read').and.returnValue(null); + const config = { configId: 'configId1' }; + const result = service.getRefreshToken(config); + + expect(result).toBeUndefined(); + expect(spy).toHaveBeenCalledOnceWith('authnResult', config); + }); + }); +}); diff --git a/src/storage/storage-persistence.service.ts b/src/storage/storage-persistence.service.ts new file mode 100644 index 0000000..5f1cea2 --- /dev/null +++ b/src/storage/storage-persistence.service.ts @@ -0,0 +1,97 @@ +import { inject, Injectable } from 'injection-js'; +import { AuthResult } from '../flows/callback-context'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { BrowserStorageService } from './browser-storage.service'; + +export type StorageKeys = + | 'authnResult' + | 'authzData' + | 'access_token_expires_at' + | 'authWellKnownEndPoints' + | 'userData' + | 'authNonce' + | 'codeVerifier' + | 'authStateControl' + | 'reusable_refresh_token' + | 'session_state' + | 'storageSilentRenewRunning' + | 'storageCodeFlowInProgress' + | 'storageCustomParamsAuthRequest' + | 'storageCustomParamsRefresh' + | 'storageCustomParamsEndSession' + | 'redirect' + | 'configIds' + | 'jwtKeys' + | 'popupauth'; + + +export class StoragePersistenceService { + private readonly browserStorageService = inject(BrowserStorageService); + + read(key: StorageKeys, config: OpenIdConfiguration): any { + const storedConfig = this.browserStorageService.read(key, config) || {}; + + return storedConfig[key]; + } + + write(key: StorageKeys, value: any, config: OpenIdConfiguration): boolean { + const storedConfig = this.browserStorageService.read(key, config) || {}; + + storedConfig[key] = value; + + return this.browserStorageService.write(storedConfig, config); + } + + remove(key: StorageKeys, config: OpenIdConfiguration): void { + const storedConfig = this.browserStorageService.read(key, config) || {}; + + delete storedConfig[key]; + + this.browserStorageService.write(storedConfig, config); + } + + clear(config: OpenIdConfiguration): void { + this.browserStorageService.clear(config); + } + + resetStorageFlowData(config: OpenIdConfiguration): void { + this.remove('session_state', config); + this.remove('storageSilentRenewRunning', config); + this.remove('storageCodeFlowInProgress', config); + this.remove('codeVerifier', config); + this.remove('userData', config); + this.remove('storageCustomParamsAuthRequest', config); + this.remove('access_token_expires_at', config); + this.remove('storageCustomParamsRefresh', config); + this.remove('storageCustomParamsEndSession', config); + this.remove('reusable_refresh_token', config); + } + + resetAuthStateInStorage(config: OpenIdConfiguration): void { + this.remove('authzData', config); + this.remove('reusable_refresh_token', config); + this.remove('authnResult', config); + } + + getAccessToken(config: OpenIdConfiguration): string { + return this.read('authzData', config); + } + + getIdToken(config: OpenIdConfiguration): string { + return this.read('authnResult', config)?.id_token; + } + + getRefreshToken(config: OpenIdConfiguration): string { + const refreshToken = this.read('authnResult', config)?.refresh_token; + + if (!refreshToken && config.allowUnsafeReuseRefreshToken) { + return this.read('reusable_refresh_token', config); + } + + return refreshToken; + } + + getAuthenticationResult(config: OpenIdConfiguration): AuthResult { + return this.read('authnResult', config); + } +} diff --git a/src/test.ts b/src/test.ts new file mode 100644 index 0000000..b4d91d6 --- /dev/null +++ b/src/test.ts @@ -0,0 +1,18 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import 'zone.js'; +import 'zone.js/testing'; +import { getTestBed } from 'injection-js/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false }, + } +); diff --git a/src/user-data/user-service.spec.ts b/src/user-data/user-service.spec.ts new file mode 100644 index 0000000..bdf8210 --- /dev/null +++ b/src/user-data/user-service.spec.ts @@ -0,0 +1,651 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Observable, of, throwError } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { createRetriableStream } from '../../test/create-retriable-stream.helper'; +import { DataService } from '../api/data.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { PlatformProvider } from '../utils/platform-provider/platform.provider'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { UserService } from './user.service'; + +const DUMMY_USER_DATA = { + sub: 'a5461470-33eb-4b2d-82d4-b0484e96ad7f', + preferred_username: 'john@test.com', + organization: 'testing', +}; + +describe('User Service', () => { + let loggerService: LoggerService; + let userService: UserService; + let storagePersistenceService: StoragePersistenceService; + let eventsService: PublicEventsService; + let dataService: DataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(StoragePersistenceService), + mockProvider(LoggerService), + mockProvider(DataService), + mockProvider(PlatformProvider), + PublicEventsService, + TokenHelperService, + FlowHelper, + ], + }); + }); + + beforeEach(() => { + loggerService = TestBed.inject(LoggerService); + userService = TestBed.inject(UserService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + eventsService = TestBed.inject(PublicEventsService); + dataService = TestBed.inject(DataService); + }); + + it('should create', () => { + expect(userService).toBeTruthy(); + }); + + it('public authorize$ is observable$', () => { + expect(userService.userData$).toEqual(jasmine.any(Observable)); + }); + + describe('getAndPersistUserDataInStore', () => { + it('if not currentFlow is NOT id Token or Code flow, return decoded ID Token - passed as argument', waitForAsync(() => { + const isRenewProcess = false; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = ''; + + const config = { + responseType: 'notcode', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(decodedIdToken).toBe(token); + }); + })); + + it('if not currentFlow is NOT id Token or Code flow, "setUserDataToStore" is called with the decodedIdToken', waitForAsync(() => { + const isRenewProcess = false; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = ''; + + const config = { + responseType: 'notcode', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + spyOn(userService, 'setUserDataToStore'); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(decodedIdToken).toBe(token); + }); + + expect(userService.setUserDataToStore).toHaveBeenCalled(); + })); + + it('if not currentFlow is id token or code flow with renewProcess going -> return existing data from storage', waitForAsync(() => { + const isRenewProcess = true; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = 'userDataInstore'; + + const config = { + responseType: 'code', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(userDataInstore).toBe(token); + }); + })); + + it('if not currentFlow is id token or code flow and not renewProcess --> ask server for data', waitForAsync(() => { + const isRenewProcess = false; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = ''; + const userDataFromSts = 'userDataFromSts'; + + const config = { + responseType: 'code', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + const spy = spyOn( + userService as any, + 'getIdentityUserData' + ).and.returnValue(of(userDataFromSts)); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(userDataFromSts).toEqual(token); + }); + + expect(spy).toHaveBeenCalled(); + })); + + it(`if not currentFlow is id token or code flow and not renewprocess + --> ask server for data + --> logging if it has userdata`, waitForAsync(() => { + const isRenewProcess = false; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = ''; + const userDataFromSts = 'userDataFromSts'; + + const config = { + responseType: 'code', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + const spy = spyOn( + userService as any, + 'getIdentityUserData' + ).and.returnValue(of(userDataFromSts)); + + spyOn(loggerService, 'logDebug'); + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(userDataFromSts).toEqual(token); + }); + + expect(spy).toHaveBeenCalled(); + expect(loggerService.logDebug).toHaveBeenCalled(); + })); + + it(`if not currentFlow is id token or code flow and not renewprocess + --> ask server for data + --> throwing Error if it has no userdata `, waitForAsync(() => { + const isRenewProcess = false; + const idToken = ''; + const decodedIdToken = { sub: 'decodedIdToken' }; + const userDataInstore = ''; + const userDataFromSts = null; + + const config = { + responseType: 'code', + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + const spyGetIdentityUserData = spyOn( + userService as any, + 'getIdentityUserData' + ).and.returnValue(of(userDataFromSts)); + + spyOn(loggerService, 'logDebug'); + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe({ + error: (err) => { + expect(err.message).toEqual( + 'Received no user data, request failed' + ); + }, + }); + + expect(spyGetIdentityUserData).toHaveBeenCalled(); + })); + + it(`if not currentFlow is id token or code flow and renewprocess and renewUserInfoAfterTokenRenew + --> ask server for data`, waitForAsync(() => { + const isRenewProcess = true; + const idToken = ''; + const decodedIdToken = 'decodedIdToken'; + const userDataInstore = 'userDataInStore'; + const userDataFromSts = 'userDataFromSts'; + + const config = { + responseType: 'code', + renewUserInfoAfterTokenRenew: true, + configId: 'configId1', + } as OpenIdConfiguration; + + spyOn(userService, 'getUserDataFromStore').and.returnValue( + userDataInstore + ); + const spy = spyOn( + userService as any, + 'getIdentityUserData' + ).and.returnValue(of(userDataFromSts)); + + userService + .getAndPersistUserDataInStore( + config, + [config], + isRenewProcess, + idToken, + decodedIdToken + ) + .subscribe((token) => { + expect(userDataFromSts).toEqual(token); + }); + + expect(spy).toHaveBeenCalled(); + })); + }); + + describe('getUserDataFromStore', () => { + it('returns null if there is not data', () => { + const config = { configId: 'configId1' }; + const result = userService.getUserDataFromStore(config); + + expect(result).toBeNull(); + }); + + it('returns value if there is data', () => { + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('userData', config) + .and.returnValue('userData'); + const result = userService.getUserDataFromStore(config); + + expect(result).toBeTruthy(); + }); + }); + + describe('setUserDataToStore', () => { + it('sets userData in storagePersistenceService', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(storagePersistenceService, 'write'); + + userService.setUserDataToStore('userDataForTest', config, [config]); + expect(spy).toHaveBeenCalledOnceWith( + 'userData', + 'userDataForTest', + config + ); + }); + + it('userDataInternal$ is called when userData is set', () => { + const config = { configId: 'configId1' }; + + const spy = spyOn((userService as any).userDataInternal$, 'next'); + + userService.setUserDataToStore('userDataForTest', config, [config]); + + expect(spy).toHaveBeenCalledOnceWith({ + userData: 'userDataForTest', + allUserData: [{ configId: 'configId1', userData: 'userDataForTest' }], + }); + }); + + it('eventService.fireEvent is called when userData is set', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(eventsService, 'fireEvent'); + + userService.setUserDataToStore('userDataForTest', config, [config]); + + expect(spy).toHaveBeenCalledOnceWith(EventTypes.UserDataChanged, { + configId: 'configId1', + userData: 'userDataForTest', + }); + }); + }); + + describe('resetUserDataInStore', () => { + it('resets userData sets null in storagePersistenceService', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(storagePersistenceService, 'remove'); + + userService.resetUserDataInStore(config, [config]); + + expect(spy).toHaveBeenCalledOnceWith('userData', config); + }); + + it('userDataInternal$ is called with null when userData is reset', () => { + const config = { configId: 'configId1' }; + const spy = spyOn((userService as any).userDataInternal$, 'next'); + + userService.resetUserDataInStore(config, [config]); + + expect(spy).toHaveBeenCalledOnceWith({ + userData: null, + allUserData: [{ configId: 'configId1', userData: null }], + }); + }); + + it('eventService.fireEvent is called with null when userData is reset', () => { + const config = { configId: 'configId1' }; + const spy = spyOn(eventsService, 'fireEvent'); + + userService.resetUserDataInStore(config, [config]); + + expect(spy).toHaveBeenCalledOnceWith(EventTypes.UserDataChanged, { + configId: 'configId1', + userData: null, + }); + }); + }); + + describe('publishUserDataIfExists', () => { + it('do nothing if no userData is stored', () => { + spyOn(userService, 'getUserDataFromStore').and.returnValue(''); + const observableSpy = spyOn( + (userService as any).userDataInternal$, + 'next' + ); + const eventSpy = spyOn(eventsService, 'fireEvent'); + const config = { configId: 'configId1' }; + + userService.publishUserDataIfExists(config, [config]); + + expect(observableSpy).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('userDataInternal is fired if userData exists with single config', () => { + spyOn(userService, 'getUserDataFromStore').and.returnValue('something'); + const observableSpy = spyOn( + (userService as any).userDataInternal$, + 'next' + ); + const config = { configId: 'configId1' }; + + userService.publishUserDataIfExists(config, [config]); + + expect(observableSpy).toHaveBeenCalledOnceWith({ + userData: 'something', + allUserData: [{ configId: 'configId1', userData: 'something' }], + }); + }); + + it('userDataInternal is fired if userData exists with multiple configs', () => { + const allConfigs = [{ configId: 'configId1' }, { configId: 'configId2' }]; + const observableSpy = spyOn( + (userService as any).userDataInternal$, + 'next' + ); + + spyOn(storagePersistenceService, 'read') + .withArgs('userData', allConfigs[0]) + .and.returnValue('somethingForConfig1') + .withArgs('userData', allConfigs[1]) + .and.returnValue('somethingForConfig2'); + + userService.publishUserDataIfExists(allConfigs[0], allConfigs); + + expect(observableSpy).toHaveBeenCalledOnceWith({ + userData: null, + allUserData: [ + { configId: 'configId1', userData: 'somethingForConfig1' }, + { configId: 'configId2', userData: 'somethingForConfig2' }, + ], + }); + }); + + it('event service UserDataChanged is fired if userData exists', () => { + const allConfigs = [{ configId: 'configId1' }, { configId: 'configId2' }]; + + spyOn(userService, 'getUserDataFromStore').and.returnValue('something'); + const eventSpy = spyOn(eventsService, 'fireEvent'); + + userService.publishUserDataIfExists(allConfigs[0], allConfigs); + + expect(eventSpy).toHaveBeenCalledOnceWith(EventTypes.UserDataChanged, { + configId: 'configId1', + userData: 'something', + }); + }); + }); + + describe('validateUserDataSubIdToken', () => { + it('with no idTokenSub returns false', () => { + const serviceAsAny = userService as any; + const config = { configId: 'configId1' }; + + const result = serviceAsAny.validateUserDataSubIdToken( + config, + '', + 'anything' + ); + + expect(result).toBeFalse(); + }); + + it('with no userDataSub returns false', () => { + const serviceAsAny = userService as any; + const config = { configId: 'configId1' }; + + const result = serviceAsAny.validateUserDataSubIdToken( + config, + 'something', + '' + ); + + expect(result).toBeFalse(); + }); + + it('with idTokenSub and userDataSub not match logs and returns false', () => { + const serviceAsAny = userService as any; + const loggerSpy = spyOn(loggerService, 'logDebug'); + const config = { configId: 'configId1' }; + + const result = serviceAsAny.validateUserDataSubIdToken( + config, + 'something', + 'something2' + ); + + expect(result).toBeFalse(); + expect(loggerSpy).toHaveBeenCalledOnceWith( + config, + 'validateUserDataSubIdToken failed', + 'something', + 'something2' + ); + }); + }); + + describe('getIdentityUserData', () => { + it('does nothing if no authWellKnownEndPoints are set', waitForAsync(() => { + const config = { configId: 'configId1' }; + const serviceAsAny = userService as any; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + serviceAsAny.getIdentityUserData(config).subscribe({ + error: (err: any) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('does nothing if no userInfoEndpoint is set', waitForAsync(() => { + const config = { configId: 'configId1' }; + const serviceAsAny = userService as any; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ userInfoEndpoint: null }); + serviceAsAny.getIdentityUserData(config).subscribe({ + error: (err: any) => { + expect(err).toBeTruthy(); + }, + }); + })); + + it('gets userData if authwell and userInfoEndpoint is set', waitForAsync(() => { + const config = { configId: 'configId1' }; + const serviceAsAny = userService as any; + const spy = spyOn(dataService, 'get').and.returnValue(of({})); + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ userInfoEndpoint: 'userInfoEndpoint' }); + serviceAsAny.getIdentityUserData(config).subscribe(() => { + expect(spy).toHaveBeenCalledOnceWith( + 'userInfoEndpoint', + config, + 'accessToken' + ); + }); + })); + }); + + it('should retry once', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ userInfoEndpoint: 'userInfoEndpoint' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + of(DUMMY_USER_DATA) + ) + ); + + (userService as any).getIdentityUserData(config).subscribe({ + next: (res: any) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_USER_DATA); + }, + }); + })); + + it('should retry twice', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ userInfoEndpoint: 'userInfoEndpoint' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of(DUMMY_USER_DATA) + ) + ); + + (userService as any).getIdentityUserData(config).subscribe({ + next: (res: any) => { + expect(res).toBeTruthy(); + expect(res).toEqual(DUMMY_USER_DATA); + }, + }); + })); + + it('should fail after three tries', waitForAsync(() => { + const config = { configId: 'configId1' }; + + spyOn(storagePersistenceService, 'getAccessToken').and.returnValue( + 'accessToken' + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ userInfoEndpoint: 'userInfoEndpoint' }); + spyOn(dataService, 'get').and.returnValue( + createRetriableStream( + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + throwError(() => new Error('Error')), + of(DUMMY_USER_DATA) + ) + ); + + (userService as any).getIdentityUserData(config).subscribe({ + error: (err: any) => { + expect(err).toBeTruthy(); + }, + }); + })); +}); diff --git a/src/user-data/user.service.ts b/src/user-data/user.service.ts new file mode 100644 index 0000000..2ef4653 --- /dev/null +++ b/src/user-data/user.service.ts @@ -0,0 +1,333 @@ +import { inject, Injectable } from 'injection-js'; +import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; +import { map, retry, switchMap } from 'rxjs/operators'; +import { DataService } from '../api/data.service'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { LoggerService } from '../logging/logger.service'; +import { EventTypes } from '../public-events/event-types'; +import { PublicEventsService } from '../public-events/public-events.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { ConfigUserDataResult, UserDataResult } from './userdata-result'; + +const DEFAULT_USERRESULT = { userData: null, allUserData: [] }; + +@Injectable() +export class UserService { + private readonly userDataInternal$ = new BehaviorSubject( + DEFAULT_USERRESULT + ); + + get userData$(): Observable { + return this.userDataInternal$.asObservable(); + } + + private readonly loggerService = inject(LoggerService); + + private readonly tokenHelperService = inject(TokenHelperService); + + private readonly flowHelper = inject(FlowHelper); + + private readonly oidcDataService = inject(DataService); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly eventService = inject(PublicEventsService); + + getAndPersistUserDataInStore( + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + isRenewProcess = false, + idToken?: string, + decodedIdToken?: any + ): Observable { + idToken = + idToken || + this.storagePersistenceService.getIdToken(currentConfiguration); + decodedIdToken = + decodedIdToken || + this.tokenHelperService.getPayloadFromToken( + idToken, + false, + currentConfiguration + ); + + const existingUserDataFromStorage = + this.getUserDataFromStore(currentConfiguration); + const haveUserData = !!existingUserDataFromStorage; + const isCurrentFlowImplicitFlowWithAccessToken = + this.flowHelper.isCurrentFlowImplicitFlowWithAccessToken( + currentConfiguration + ); + const isCurrentFlowCodeFlow = + this.flowHelper.isCurrentFlowCodeFlow(currentConfiguration); + + const accessToken = + this.storagePersistenceService.getAccessToken(currentConfiguration); + + if (!(isCurrentFlowImplicitFlowWithAccessToken || isCurrentFlowCodeFlow)) { + this.loggerService.logDebug( + currentConfiguration, + `authCallback idToken flow with accessToken ${accessToken}` + ); + + this.setUserDataToStore(decodedIdToken, currentConfiguration, allConfigs); + + return of(decodedIdToken); + } + + const { renewUserInfoAfterTokenRenew } = currentConfiguration; + + if (!isRenewProcess || renewUserInfoAfterTokenRenew || !haveUserData) { + return this.getUserDataOidcFlowAndSave( + decodedIdToken.sub, + currentConfiguration, + allConfigs + ).pipe( + switchMap((userData) => { + this.loggerService.logDebug( + currentConfiguration, + 'Received user data: ', + userData + ); + if (!!userData) { + this.loggerService.logDebug( + currentConfiguration, + 'accessToken: ', + accessToken + ); + + return of(userData); + } else { + return throwError( + () => new Error('Received no user data, request failed') + ); + } + }) + ); + } + + return of(existingUserDataFromStorage); + } + + getUserDataFromStore(currentConfiguration: OpenIdConfiguration | null): any { + if (!currentConfiguration) { + return throwError( + () => + new Error( + 'Please provide a configuration before setting up the module' + ) + ); + } + + return ( + this.storagePersistenceService.read('userData', currentConfiguration) || + null + ); + } + + publishUserDataIfExists( + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + const userData = this.getUserDataFromStore(currentConfiguration); + + if (userData) { + this.fireUserDataEvent(currentConfiguration, allConfigs, userData); + } + } + + setUserDataToStore( + userData: any, + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + this.storagePersistenceService.write( + 'userData', + userData, + currentConfiguration + ); + this.fireUserDataEvent(currentConfiguration, allConfigs, userData); + } + + resetUserDataInStore( + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): void { + this.storagePersistenceService.remove('userData', currentConfiguration); + this.fireUserDataEvent(currentConfiguration, allConfigs, null); + } + + private getUserDataOidcFlowAndSave( + idTokenSub: any, + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[] + ): Observable { + return this.getIdentityUserData(currentConfiguration).pipe( + map((data: any) => { + if ( + this.validateUserDataSubIdToken( + currentConfiguration, + idTokenSub, + data?.sub + ) + ) { + this.setUserDataToStore(data, currentConfiguration, allConfigs); + + return data; + } else { + // something went wrong, user data sub does not match that from id_token + this.loggerService.logWarning( + currentConfiguration, + `User data sub does not match sub in id_token, resetting` + ); + this.resetUserDataInStore(currentConfiguration, allConfigs); + + return null; + } + }) + ); + } + + private getIdentityUserData( + currentConfiguration: OpenIdConfiguration + ): Observable { + const token = + this.storagePersistenceService.getAccessToken(currentConfiguration); + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + currentConfiguration + ); + + if (!authWellKnownEndPoints) { + this.loggerService.logWarning( + currentConfiguration, + 'init check session: authWellKnownEndpoints is undefined' + ); + + return throwError(() => new Error('authWellKnownEndpoints is undefined')); + } + + const userInfoEndpoint = authWellKnownEndPoints.userInfoEndpoint; + + if (!userInfoEndpoint) { + this.loggerService.logError( + currentConfiguration, + 'init check session: authWellKnownEndpoints.userinfo_endpoint is undefined; set auto_userinfo = false in config' + ); + + return throwError( + () => new Error('authWellKnownEndpoints.userinfo_endpoint is undefined') + ); + } + + return this.oidcDataService + .get(userInfoEndpoint, currentConfiguration, token) + .pipe(retry(2)); + } + + private validateUserDataSubIdToken( + currentConfiguration: OpenIdConfiguration, + idTokenSub: any, + userDataSub: any + ): boolean { + if (!idTokenSub) { + return false; + } + + if (!userDataSub) { + return false; + } + + if (idTokenSub.toString() !== userDataSub.toString()) { + this.loggerService.logDebug( + currentConfiguration, + 'validateUserDataSubIdToken failed', + idTokenSub, + userDataSub + ); + + return false; + } + + return true; + } + + private fireUserDataEvent( + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + passedUserData: any + ): void { + const userData = this.composeSingleOrMultipleUserDataObject( + currentConfiguration, + allConfigs, + passedUserData + ); + + this.userDataInternal$.next(userData); + + const { configId } = currentConfiguration; + + this.eventService.fireEvent(EventTypes.UserDataChanged, { + configId, + userData: passedUserData, + }); + } + + private composeSingleOrMultipleUserDataObject( + currentConfiguration: OpenIdConfiguration, + allConfigs: OpenIdConfiguration[], + passedUserData: any + ): UserDataResult { + const hasManyConfigs = allConfigs.length > 1; + + if (!hasManyConfigs) { + const { configId } = currentConfiguration; + + return this.composeSingleUserDataResult(configId ?? '', passedUserData); + } + + const allUserData: ConfigUserDataResult[] = allConfigs.map((config) => { + const currentConfigId = currentConfiguration.configId ?? ''; + const configId = config.configId ?? ''; + + if (this.currentConfigIsToUpdate(currentConfigId, config)) { + return { configId, userData: passedUserData }; + } + + const alreadySavedUserData = + this.storagePersistenceService.read('userData', config) || null; + + return { + configId, + userData: alreadySavedUserData, + }; + }); + + return { + userData: null, + allUserData, + }; + } + + private composeSingleUserDataResult( + configId: string, + userData: any + ): UserDataResult { + return { + userData, + allUserData: [{ configId, userData }], + }; + } + + private currentConfigIsToUpdate( + configId: string, + config: OpenIdConfiguration + ): boolean { + return config.configId === configId; + } +} diff --git a/src/user-data/userdata-result.ts b/src/user-data/userdata-result.ts new file mode 100644 index 0000000..40744fc --- /dev/null +++ b/src/user-data/userdata-result.ts @@ -0,0 +1,9 @@ +export interface UserDataResult { + userData: any; + allUserData: ConfigUserDataResult[]; +} + +export interface ConfigUserDataResult { + configId: string; + userData: any; +} diff --git a/src/utils/collections/collections.helper.ts b/src/utils/collections/collections.helper.ts new file mode 100644 index 0000000..f83619f --- /dev/null +++ b/src/utils/collections/collections.helper.ts @@ -0,0 +1,7 @@ +export function flattenArray(array: any[][]): any[] { + return array.reduce( + (flattened, elem) => + flattened.concat(Array.isArray(elem) ? flattenArray(elem) : elem), + [] + ); +} diff --git a/src/utils/crypto/crypto.service.spec.ts b/src/utils/crypto/crypto.service.spec.ts new file mode 100644 index 0000000..42f91e5 --- /dev/null +++ b/src/utils/crypto/crypto.service.spec.ts @@ -0,0 +1,71 @@ +import { DOCUMENT } from '../../dom'; +import { TestBed } from '@angular/core/testing'; +import { CryptoService } from './crypto.service'; + +describe('CryptoService', () => { + let cryptoService: CryptoService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CryptoService, + { + provide: DOCUMENT, + useValue: { defaultView: { crypto: 'some-thing' } }, + }, + ], + }); + }); + + beforeEach(() => { + cryptoService = TestBed.inject(CryptoService); + }); + + it('should create', () => { + expect(cryptoService).toBeTruthy(); + }); + + it('should return crypto if crypto is present', () => { + // arrange + + // act + const crypto = cryptoService.getCrypto(); + + // assert + expect(crypto).toBe('some-thing'); + }); +}); + +describe('CryptoService: msCrypto', () => { + let cryptoService: CryptoService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CryptoService, + { + provide: DOCUMENT, + useValue: { defaultView: { msCrypto: 'some-msCrypto-thing' } }, + }, + ], + }); + }); + + beforeEach(() => { + cryptoService = TestBed.inject(CryptoService); + }); + + it('should create', () => { + expect(cryptoService).toBeTruthy(); + }); + + it('should return crypto if crypto is present', () => { + // arrange + + // act + const crypto = cryptoService.getCrypto(); + + // assert + expect(crypto).toBe('some-msCrypto-thing'); + }); +}); diff --git a/src/utils/crypto/crypto.service.ts b/src/utils/crypto/crypto.service.ts new file mode 100644 index 0000000..6639148 --- /dev/null +++ b/src/utils/crypto/crypto.service.ts @@ -0,0 +1,15 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; + +@Injectable() +export class CryptoService { + private readonly document = inject(DOCUMENT); + + getCrypto(): any { + // support for IE, (window.crypto || window.msCrypto) + return ( + this.document.defaultView?.crypto || + (this.document.defaultView as any)?.msCrypto + ); + } +} diff --git a/src/utils/equality/equality.service.spec.ts b/src/utils/equality/equality.service.spec.ts new file mode 100644 index 0000000..98f18f7 --- /dev/null +++ b/src/utils/equality/equality.service.spec.ts @@ -0,0 +1,217 @@ +import { TestBed } from '@angular/core/testing'; +import { IFrameService } from '../../iframe/existing-iframe.service'; +import { EqualityService } from './equality.service'; + +describe('EqualityService Tests', () => { + let equalityHelperService: EqualityService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [EqualityService, IFrameService], + }); + }); + + beforeEach(() => { + equalityHelperService = TestBed.inject(EqualityService); + }); + + it('should create', () => { + expect(equalityHelperService).toBeTruthy(); + }); + + describe('areEqual', () => { + it('first arg is null returns false', () => { + const result = equalityHelperService.areEqual(null, 'somestring'); + + expect(result).toBe(false); + }); + + it('second arg is null returns false', () => { + const result = equalityHelperService.areEqual('somestring', null); + + expect(result).toBe(false); + }); + + it('first arg is undefined returns false', () => { + const result = equalityHelperService.areEqual(undefined, 'somestring'); + + expect(result).toBe(false); + }); + + it('second arg is undefined returns false', () => { + const result = equalityHelperService.areEqual('somestring', undefined); + + expect(result).toBe(false); + }); + + it('two empty strings return false', () => { + const result = equalityHelperService.areEqual('', ''); + + expect(result).toBe(false); + }); + + it('two equal strings return true', () => { + const result = equalityHelperService.areEqual('somestring', 'somestring'); + + expect(result).toBe(true); + }); + + it('two equal strings but different casing returns false', () => { + const result = equalityHelperService.areEqual('somestring', 'Somestring'); + + expect(result).toBe(false); + }); + + it('two same arrays return true', () => { + const array1 = ['somestring1', 'somestring2']; + const array2 = ['somestring1', 'somestring2']; + const result = equalityHelperService.areEqual(array1, array2); + + expect(result).toBe(true); + }); + + it('two same arrays (bit not casing) return false', () => { + const array1 = ['somestring1', 'SOMESTRING2']; + const array2 = ['somestring1', 'somestring2']; + const result = equalityHelperService.areEqual(array1, array2); + + expect(result).toBe(false); + }); + + it('two different arrays return false', () => { + const array1 = ['somestring1']; + const array2 = ['somestring1', 'somestring2']; + const result = equalityHelperService.areEqual(array1, array2); + + expect(result).toBe(false); + }); + + it('two same objects return true', () => { + const object1 = { name: 'Phil', age: 67 }; + const object2 = { name: 'Phil', age: 67 }; + const result = equalityHelperService.areEqual(object1, object2); + + expect(result).toBe(true); + }); + + it('two different objects return false', () => { + const object1 = { name: 'Phil', age: 67 }; + const object2 = { name: 'Mike', age: 67 }; + const result = equalityHelperService.areEqual(object1, object2); + + expect(result).toBe(false); + }); + + it('string and array (same) return true', () => { + const array1 = ['somestring1']; + const string2 = 'somestring1'; + const result = equalityHelperService.areEqual(array1, string2); + + expect(result).toBe(true); + }); + + it('array and string (same) return true', () => { + const array1 = ['somestring1']; + const string2 = 'somestring1'; + const result = equalityHelperService.areEqual(string2, array1); + + expect(result).toBe(true); + }); + + it('string and array (different) return false', () => { + const array1 = ['somestring']; + const string2 = 'somestring1'; + const result = equalityHelperService.areEqual(string2, array1); + + expect(result).toBe(false); + }); + + it('array and string (same) return true', () => { + const array1 = ['somestring1']; + const string2 = 'somestring'; + const result = equalityHelperService.areEqual(array1, string2); + + expect(result).toBe(false); + }); + + it('boolean and boolean return false', () => { + const result = equalityHelperService.areEqual(true, false); + + expect(result).toBe(false); + }); + + it('boolean and boolean return false', () => { + const result = equalityHelperService.areEqual(true, true); + + expect(result).toBe(true); + }); + }); + + describe('isStringEqualOrNonOrderedArrayEqual', () => { + const testCases = [ + { + input1: 'value1', + input2: 'value1', + expected: true, + }, + { + input1: 'value1', + input2: 'value2', + expected: false, + }, + // old "x" (string) , [x] new invalid + { + input1: 'value1', + input2: ['value2'] as any[], + expected: false, + }, + // old [x], new "x" (string) invalid + { + input1: ['value2'] as any[], + input2: 'value1', + expected: false, + }, + { + input1: ['value1'] as any[], + input2: ['value2'] as any[], + expected: false, + }, + // old [x,y,z], new [x,y] invalid + // old [x], new [y,x] invalid + { + input1: ['value1'] as any[], + input2: ['value1', 'value2'] as any[], + expected: false, + }, + { + input1: ['value1', 'value2'] as any[], + input2: ['value1', 'value2'] as any[], + expected: true, + }, + // old [x,y], new [y,x] valid + { + input1: ['value1', 'value2'] as any[], + input2: ['value2', 'value1'] as any[], + expected: true, + }, + // old [x,y,z], new [y,z,x] valid + { + input1: ['x', 'y', 'z'] as any[], + input2: ['y', 'z', 'x'] as any[], + expected: true, + }, + ]; + + testCases.forEach(({ input1, input2, expected }) => { + it(`returns '${expected}' if '${input1}' and '${input2}' is given`, () => { + const result = + equalityHelperService.isStringEqualOrNonOrderedArrayEqual( + input1, + input2 + ); + + expect(result).toBe(expected); + }); + }); + }); +}); diff --git a/src/utils/equality/equality.service.ts b/src/utils/equality/equality.service.ts new file mode 100644 index 0000000..09e712f --- /dev/null +++ b/src/utils/equality/equality.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from 'injection-js'; + +@Injectable() +export class EqualityService { + isStringEqualOrNonOrderedArrayEqual( + value1: string | any[], + value2: string | any[] + ): boolean { + if (this.isNullOrUndefined(value1)) { + return false; + } + + if (this.isNullOrUndefined(value2)) { + return false; + } + + if (this.oneValueIsStringAndTheOtherIsArray(value1, value2)) { + return false; + } + + if (this.bothValuesAreStrings(value1, value2)) { + return value1 === value2; + } + + return this.arraysHaveEqualContent(value1 as any[], value2 as any[]); + } + + areEqual( + value1: string | any[] | any | null | undefined, + value2: string | any[] | any | null | undefined + ): boolean { + if (!value1 || !value2) { + return false; + } + + if (this.bothValuesAreArrays(value1, value2)) { + return this.arraysStrictEqual(value1 as any[], value2 as any[]); + } + + if (this.bothValuesAreStrings(value1, value2)) { + return value1 === value2; + } + + if (this.bothValuesAreObjects(value1, value2)) { + return ( + JSON.stringify(value1).toLowerCase() === + JSON.stringify(value2).toLowerCase() + ); + } + + if (this.oneValueIsStringAndTheOtherIsArray(value1, value2)) { + if (Array.isArray(value1) && this.valueIsString(value2)) { + return value1[0] === value2; + } + if (Array.isArray(value2) && this.valueIsString(value1)) { + return value2[0] === value1; + } + } + + return value1 === value2; + } + + private oneValueIsStringAndTheOtherIsArray( + value1: string | any | any[], + value2: string | any | any[] + ): boolean { + return ( + (Array.isArray(value1) && this.valueIsString(value2)) || + (Array.isArray(value2) && this.valueIsString(value1)) + ); + } + + private bothValuesAreObjects( + value1: string | any | any[], + value2: string | any | any[] + ): boolean { + return this.valueIsObject(value1) && this.valueIsObject(value2); + } + + private bothValuesAreStrings( + value1: string | any | any[], + value2: string | any | any[] + ): boolean { + return this.valueIsString(value1) && this.valueIsString(value2); + } + + private bothValuesAreArrays( + value1: string | any | any[], + value2: string | any | any[] + ): boolean { + return Array.isArray(value1) && Array.isArray(value2); + } + + private valueIsString(value: any): boolean { + return typeof value === 'string' || value instanceof String; + } + + private valueIsObject(value: any): boolean { + return typeof value === 'object'; + } + + private arraysStrictEqual(arr1: Array, arr2: Array): boolean { + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = arr1.length; i--; ) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + + return true; + } + + private arraysHaveEqualContent( + arr1: Array, + arr2: Array + ): boolean { + if (arr1.length !== arr2.length) { + return false; + } + + return arr1.some((v) => arr2.includes(v)); + } + + private isNullOrUndefined(val: any): boolean { + return val === null || val === undefined; + } +} diff --git a/src/utils/flowHelper/flow-helper.service.spec.ts b/src/utils/flowHelper/flow-helper.service.spec.ts new file mode 100644 index 0000000..52849ab --- /dev/null +++ b/src/utils/flowHelper/flow-helper.service.spec.ts @@ -0,0 +1,171 @@ +import { TestBed } from '@angular/core/testing'; +import { FlowHelper } from './flow-helper.service'; + +describe('Flow Helper Service', () => { + let flowHelper: FlowHelper; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [FlowHelper], + }); + }); + + beforeEach(() => { + flowHelper = TestBed.inject(FlowHelper); + }); + + it('should create', () => { + expect(flowHelper).toBeTruthy(); + }); + + it('isCurrentFlowCodeFlow returns false if current flow is not code flow', () => { + const config = { responseType: 'id_token token', configId: 'configId1' }; + + expect(flowHelper.isCurrentFlowCodeFlow(config)).toBeFalse(); + }); + + it('isCurrentFlowCodeFlow returns true if current flow is code flow', () => { + const config = { responseType: 'code' }; + + expect(flowHelper.isCurrentFlowCodeFlow(config)).toBeTrue(); + }); + + it('currentFlowIs returns true if current flow is code flow', () => { + const config = { responseType: 'code' }; + + expect(flowHelper.currentFlowIs('code', config)).toBeTrue(); + }); + + it('currentFlowIs returns true if current flow is code flow (array)', () => { + const config = { responseType: 'code' }; + + expect(flowHelper.currentFlowIs(['code'], config)).toBeTrue(); + }); + + it('currentFlowIs returns true if current flow is id_token token or code (array)', () => { + const config = { responseType: 'id_token token' }; + + expect( + flowHelper.currentFlowIs(['id_token token', 'code'], config) + ).toBeTrue(); + }); + + it('currentFlowIs returns true if current flow is code flow', () => { + const config = { responseType: 'id_token token' }; + + expect(flowHelper.currentFlowIs('code', config)).toBeFalse(); + }); + + it('isCurrentFlowImplicitFlowWithAccessToken return true if flow is "id_token token"', () => { + const config = { responseType: 'id_token token' }; + + const result = flowHelper.isCurrentFlowImplicitFlowWithAccessToken(config); + + expect(result).toBeTrue(); + }); + + it('isCurrentFlowImplicitFlowWithAccessToken return false if flow is not "id_token token"', () => { + const config = { responseType: 'id_token2 token2' }; + + const result = flowHelper.isCurrentFlowImplicitFlowWithAccessToken(config); + + expect(result).toBeFalse(); + }); + + it('isCurrentFlowImplicitFlowWithoutAccessToken return true if flow is "id_token"', () => { + const config = { responseType: 'id_token' }; + + const result = ( + flowHelper as any + ).isCurrentFlowImplicitFlowWithoutAccessToken(config); + + expect(result).toBeTrue(); + }); + + it('isCurrentFlowImplicitFlowWithoutAccessToken return false if flow is not "id_token token"', () => { + const config = { responseType: 'id_token2' }; + + const result = ( + flowHelper as any + ).isCurrentFlowImplicitFlowWithoutAccessToken(config); + + expect(result).toBeFalse(); + }); + + it('isCurrentFlowCodeFlowWithRefreshTokens return false if flow is not code flow', () => { + const config = { responseType: 'not code' }; + + const result = flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config); + + expect(result).toBeFalse(); + }); + + it('isCurrentFlowCodeFlowWithRefreshTokens return false if useRefreshToken is set to false', () => { + const config = { responseType: 'not code', useRefreshToken: false }; + + const result = flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config); + + expect(result).toBeFalse(); + }); + + it('isCurrentFlowCodeFlowWithRefreshTokens return true if useRefreshToken is set to true and code flow', () => { + const config = { responseType: 'code', useRefreshToken: true }; + + const result = flowHelper.isCurrentFlowCodeFlowWithRefreshTokens(config); + + expect(result).toBeTrue(); + }); + + describe('isCurrentFlowAnyImplicitFlow', () => { + it('returns true if currentFlowIsImplicitFlowWithAccessToken is true', () => { + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(true); + spyOn( + flowHelper as any, + 'isCurrentFlowImplicitFlowWithoutAccessToken' + ).and.returnValue(false); + + const result = flowHelper.isCurrentFlowAnyImplicitFlow({ + configId: 'configId1', + }); + + expect(result).toBeTrue(); + }); + + it('returns true if isCurrentFlowImplicitFlowWithoutAccessToken is true', () => { + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(false); + spyOn( + flowHelper as any, + 'isCurrentFlowImplicitFlowWithoutAccessToken' + ).and.returnValue(true); + + const result = flowHelper.isCurrentFlowAnyImplicitFlow({ + configId: 'configId1', + }); + + expect(result).toBeTrue(); + }); + + it('returns false it is not any implicit flow', () => { + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(false); + spyOn( + flowHelper as any, + 'isCurrentFlowImplicitFlowWithoutAccessToken' + ).and.returnValue(false); + + const result = flowHelper.isCurrentFlowAnyImplicitFlow({ + configId: 'configId1', + }); + + expect(result).toBeFalse(); + }); + }); +}); diff --git a/src/utils/flowHelper/flow-helper.service.ts b/src/utils/flowHelper/flow-helper.service.ts new file mode 100644 index 0000000..f9454b4 --- /dev/null +++ b/src/utils/flowHelper/flow-helper.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; + +@Injectable() +export class FlowHelper { + isCurrentFlowCodeFlow(configuration: OpenIdConfiguration): boolean { + return this.currentFlowIs('code', configuration); + } + + isCurrentFlowAnyImplicitFlow(configuration: OpenIdConfiguration): boolean { + return ( + this.isCurrentFlowImplicitFlowWithAccessToken(configuration) || + this.isCurrentFlowImplicitFlowWithoutAccessToken(configuration) + ); + } + + isCurrentFlowCodeFlowWithRefreshTokens( + configuration: OpenIdConfiguration | null + ): boolean { + if (!configuration) { + return false; + } + + const { useRefreshToken } = configuration; + + return ( + this.isCurrentFlowCodeFlow(configuration) && Boolean(useRefreshToken) + ); + } + + isCurrentFlowImplicitFlowWithAccessToken( + configuration: OpenIdConfiguration + ): boolean { + return this.currentFlowIs('id_token token', configuration); + } + + currentFlowIs( + flowTypes: string[] | string, + configuration: OpenIdConfiguration + ): boolean { + const { responseType } = configuration; + + if (Array.isArray(flowTypes)) { + return flowTypes.some((x) => responseType === x); + } + + return responseType === flowTypes; + } + + private isCurrentFlowImplicitFlowWithoutAccessToken( + configuration: OpenIdConfiguration + ): boolean { + return this.currentFlowIs('id_token', configuration); + } +} diff --git a/src/utils/object/object.helper.ts b/src/utils/object/object.helper.ts new file mode 100644 index 0000000..71a001a --- /dev/null +++ b/src/utils/object/object.helper.ts @@ -0,0 +1,11 @@ +export function removeNullAndUndefinedValues(obj: any): any { + const copy = { ...obj }; + + for (const key in obj) { + if (obj[key] === undefined || obj[key] === null) { + delete copy[key]; + } + } + + return copy; +} diff --git a/src/utils/platform-provider/platform-provider.spec.ts b/src/utils/platform-provider/platform-provider.spec.ts new file mode 100644 index 0000000..979a91c --- /dev/null +++ b/src/utils/platform-provider/platform-provider.spec.ts @@ -0,0 +1,44 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { PlatformProvider } from './platform.provider'; + +describe('PlatformProvider Tests', () => { + it('should create', () => { + TestBed.configureTestingModule({ + providers: [ + PlatformProvider, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + const service = TestBed.inject(PlatformProvider); + + expect(service).toBeTruthy(); + }); + + it('isBrowser equals true if "isPlatformBrowser" is true', () => { + TestBed.configureTestingModule({ + providers: [ + PlatformProvider, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + const service = TestBed.inject(PlatformProvider); + + expect(service.isBrowser()).toBe(true); + }); + + it('isBrowser equals true if "isPlatformBrowser" is true', () => { + TestBed.configureTestingModule({ + providers: [ + PlatformProvider, + { provide: PLATFORM_ID, useValue: 'notABrowser' }, + ], + }); + + const service = TestBed.inject(PlatformProvider); + + expect(service.isBrowser()).toBe(false); + }); +}); diff --git a/src/utils/platform-provider/platform.provider.ts b/src/utils/platform-provider/platform.provider.ts new file mode 100644 index 0000000..d7c1645 --- /dev/null +++ b/src/utils/platform-provider/platform.provider.ts @@ -0,0 +1,11 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject } from 'injection-js'; + +@Injectable() +export class PlatformProvider { + private readonly platformId = inject(PLATFORM_ID); + + isBrowser(): boolean { + return isPlatformBrowser(this.platformId); + } +} diff --git a/src/utils/redirect/redirect.service.spec.ts b/src/utils/redirect/redirect.service.spec.ts new file mode 100644 index 0000000..786e5ad --- /dev/null +++ b/src/utils/redirect/redirect.service.spec.ts @@ -0,0 +1,46 @@ +import { DOCUMENT } from '../../dom'; +import { TestBed } from '@angular/core/testing'; +import { RedirectService } from './redirect.service'; + +describe('Redirect Service Tests', () => { + let service: RedirectService; + let myDocument: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RedirectService, + { + provide: DOCUMENT, + useValue: { + location: { + get href(): string { + return 'fakeUrl'; + }, + set href(_value) { + // ... + }, + }, + }, + }, + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(RedirectService); + myDocument = TestBed.inject(DOCUMENT); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + expect(myDocument).toBeTruthy(); + }); + + it('redirectTo sets window location href', () => { + const spy = spyOnProperty(myDocument.location, 'href', 'set'); + + service.redirectTo('anyurl'); + expect(spy).toHaveBeenCalledOnceWith('anyurl'); + }); +}); diff --git a/src/utils/redirect/redirect.service.ts b/src/utils/redirect/redirect.service.ts new file mode 100644 index 0000000..1453351 --- /dev/null +++ b/src/utils/redirect/redirect.service.ts @@ -0,0 +1,12 @@ + +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; + +@Injectable() +export class RedirectService { + private readonly document = inject(DOCUMENT); + + redirectTo(url: string): void { + this.document.location.href = url; + } +} diff --git a/src/utils/tokenHelper/token-helper.service.spec.ts b/src/utils/tokenHelper/token-helper.service.spec.ts new file mode 100644 index 0000000..d3ee296 --- /dev/null +++ b/src/utils/tokenHelper/token-helper.service.spec.ts @@ -0,0 +1,485 @@ +import { TestBed } from '@angular/core/testing'; +import { mockProvider } from '../../../test/auto-mock'; +import { LoggerService } from '../../logging/logger.service'; +import { TokenHelperService } from './token-helper.service'; + +describe('Token Helper Service', () => { + let tokenHelperService: TokenHelperService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [mockProvider(LoggerService)], + }); + }); + + beforeEach(() => { + tokenHelperService = TestBed.inject(TokenHelperService); + }); + + it('should create', () => { + expect(tokenHelperService).toBeTruthy(); + }); + + describe('getTokenExpirationDate', () => { + it('returns not null if param has no property exp', () => { + const result = tokenHelperService.getTokenExpirationDate({}); + + expect(result).toBeDefined(); + }); + + it('returns date if param has no property exp', () => { + const result = tokenHelperService.getTokenExpirationDate({}); + + expect(result instanceof Date).toBe(true); + }); + + it('returns correct date if param has property exp', () => { + const expectedDate = new Date(0); + + expectedDate.setUTCSeconds(123); + + const result = tokenHelperService.getTokenExpirationDate({ + exp: 123, + }); + + expect(expectedDate.toString()).toEqual(result.toString()); + }); + }); + + describe('getSigningInputFromToken', () => { + it('returns empty string if token is not valid', () => { + const result = tokenHelperService.getSigningInputFromToken('', true, { + configId: 'configId1', + }); + + expect(result).toBe(''); + }); + + it('returns string if token is valid', () => { + const token = 'a.valid.token'; + const result = tokenHelperService.getSigningInputFromToken(token, true, { + configId: 'configId1', + }); + + expect(result).toBe('a.valid'); + }); + }); + + describe('getPayloadFromToken', () => { + it('returns not null if token is undefined, encode is false', () => { + const result = tokenHelperService.getPayloadFromToken(undefined, false, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is undefined, encode is true', () => { + const result = tokenHelperService.getPayloadFromToken(undefined, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is null, encode is true', () => { + const result = tokenHelperService.getPayloadFromToken(null, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is empty, encode is true', () => { + const result = tokenHelperService.getPayloadFromToken('', true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is true', () => { + const result = tokenHelperService.getPayloadFromToken( + 'testStringWithoutDots', + true, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is false', () => { + const result = tokenHelperService.getPayloadFromToken( + 'testStringWithoutDots', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has only one point, encode is false', () => { + const result = tokenHelperService.getPayloadFromToken( + 'testStringWith.dot', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns payload if token is correct, encode is true 1', () => { + const token = 'abc.def.ghi'; + const expected = 'def'; + const result = tokenHelperService.getPayloadFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true 2', () => { + const token = 'abc.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.ghi'; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getPayloadFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true 3', () => { + const token = 'abc.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.ghi'; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getPayloadFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true 4', () => { + const token = + 'SGVsbG8gV29ybGQgMTIzIQ==.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.SGVsbG8gV29ybGQgMTIzIQ=='; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getPayloadFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false 1', () => { + const token = 'abc.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.ghi'; + const expected = JSON.parse('{ "text" : "Hello World 123!"}'); + const result = tokenHelperService.getPayloadFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false 2', () => { + const token = + 'SGVsbG8gV29ybGQgMTIzIQ==.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.SGVsbG8gV29ybGQgMTIzIQ=='; + const expected = JSON.parse(`{ "text" : "Hello World 123!"}`); + const result = tokenHelperService.getPayloadFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false 3', () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEw7PDqyJ9.wMn-1oLWnxKJolMGb7YKnlwjqusWf4xnnjABgFaDkI4'; + const jsonString = `{ "name" : "John D\xF3\xEB" }`; + const expected = JSON.parse(jsonString); + const result = tokenHelperService.getPayloadFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false 4', () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJzdWIiOiIxIiwibmFtZSI6IkpvaG4gRF83NDc377-977-9MDEiLCJhZG1pbiI6dHJ1ZSwiaWF0IjoxNTE2MjI0MjQyfQ' + + '.RqIi_sO2g592anknIvfks4p7kPy8mOcN0YZUHz-8pFw'; + + const jsonString = `{ "admin": true, "sub": "1", "iat": 1516224242 }`; + const expected = JSON.parse(jsonString); + const result = tokenHelperService.getPayloadFromToken(token, false, { + configId: 'configId1', + }); + + expect(result).toEqual(jasmine.objectContaining(expected)); + }); + }); + + describe('getHeaderFromToken', () => { + it('returns not null if token is undefined, encode is false', () => { + const result = tokenHelperService.getHeaderFromToken(undefined, false, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is undefined, encode is true', () => { + const result = tokenHelperService.getHeaderFromToken(undefined, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is null, encode is true', () => { + const result = tokenHelperService.getHeaderFromToken(null, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is empty, encode is true', () => { + const result = tokenHelperService.getHeaderFromToken('', true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is true', () => { + const result = tokenHelperService.getHeaderFromToken( + 'testStringWithoutDots', + true, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is false', () => { + const result = tokenHelperService.getHeaderFromToken( + 'testStringWithoutDots', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has only one point, encode is false', () => { + const result = tokenHelperService.getHeaderFromToken( + 'testStringWith.dot', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'abc.def.ghi'; + const expected = 'abc'; + const result = tokenHelperService.getHeaderFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.def.ghi'; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getHeaderFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false', () => { + const token = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.def.ghi'; + const expected = JSON.parse(`{ "text" : "Hello World 123!"}`); + const result = tokenHelperService.getHeaderFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false', () => { + const token = + 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.SGVsbG8gV29ybGQgMTIzIQ==.SGVsbG8gV29ybGQgMTIzIQ=='; + const expected = JSON.parse(`{ "text" : "Hello World 123!"}`); + const result = tokenHelperService.getHeaderFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.abc.ghi'; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getHeaderFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = + 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.SGVsbG8gV29ybGQgMTIzIQ==.SGVsbG8gV29ybGQgMTIzIQ=='; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getHeaderFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false', () => { + const token = + 'eyJ0ZXh0IjogIkhlbGxvIFdvcmxkIDEyMyEifQ=.SGVsbG8gV29ybGQgMTIzIQ==.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const expected = JSON.parse(`{"text": "Hello World 123!"}`); + const result = tokenHelperService.getHeaderFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + }); + + describe('getSignatureFromToken', () => { + it('returns not null if token is undefined, encode is false', () => { + const result = tokenHelperService.getSignatureFromToken( + undefined, + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token is undefined, encode is true', () => { + const result = tokenHelperService.getSignatureFromToken(undefined, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is null, encode is true', () => { + const result = tokenHelperService.getSignatureFromToken(null, true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token is empty, encode is true', () => { + const result = tokenHelperService.getSignatureFromToken('', true, { + configId: 'configId1', + }); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is true', () => { + const result = tokenHelperService.getSignatureFromToken( + 'testStringWithoutDots', + true, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has no points, encode is false', () => { + const result = tokenHelperService.getSignatureFromToken( + 'testStringWithoutDots', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns not null if token has only one point, encode is false', () => { + const result = tokenHelperService.getSignatureFromToken( + 'testStringWith.dot', + false, + { configId: 'configId1' } + ); + + expect(result).toEqual({}); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'abc.def.ghi'; + const expected = 'ghi'; + const result = tokenHelperService.getSignatureFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'def.ghi.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const expected = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const result = tokenHelperService.getSignatureFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false', () => { + const token = 'def.ghi.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const expected = JSON.parse(`{ "text" : "Hello World 123!"}`); + const result = tokenHelperService.getSignatureFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is false', () => { + const token = + 'SGVsbG8gV29ybGQgMTIzIQ==.SGVsbG8gV29ybGQgMTIzIQ==.eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9'; + const expected = JSON.parse(`{ "text" : "Hello World 123!"}`); + const result = tokenHelperService.getSignatureFromToken(token, false, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.abc.ghi'; + const expected = 'ghi'; + const result = tokenHelperService.getSignatureFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + + it('returns payload if token is correct, encode is true', () => { + const token = + 'eyAidGV4dCIgOiAiSGVsbG8gV29ybGQgMTIzISJ9.SGVsbG8gV29ybGQgMTIzIQ==.SGVsbG8gV29ybGQgMTIzIQ=='; + const expected = 'SGVsbG8gV29ybGQgMTIzIQ=='; + const result = tokenHelperService.getSignatureFromToken(token, true, { + configId: 'configId1', + }); + + expect(expected).toEqual(result); + }); + }); +}); diff --git a/src/utils/tokenHelper/token-helper.service.ts b/src/utils/tokenHelper/token-helper.service.ts new file mode 100644 index 0000000..31ad11c --- /dev/null +++ b/src/utils/tokenHelper/token-helper.service.ts @@ -0,0 +1,182 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { LoggerService } from '../../logging/logger.service'; + +const PARTS_OF_TOKEN = 3; + +@Injectable() +export class TokenHelperService { + private readonly loggerService = inject(LoggerService); + + private readonly document = inject(DOCUMENT); + + getTokenExpirationDate(dataIdToken: any): Date { + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'exp')) { + return new Date(new Date().toUTCString()); + } + + const date = new Date(0); // The 0 here is the key, which sets the date to the epoch + + date.setUTCSeconds(dataIdToken.exp); + + return date; + } + + getSigningInputFromToken( + token: string | undefined | null, + encoded: boolean, + configuration: OpenIdConfiguration + ): string { + if (!this.tokenIsValid(token, configuration)) { + return ''; + } + + const header: string = this.getHeaderFromToken( + token, + encoded, + configuration + ); + const payload: string = this.getPayloadFromToken( + token, + encoded, + configuration + ); + + return [header, payload].join('.'); + } + + getHeaderFromToken( + token: string | undefined | null, + encoded: boolean, + configuration: OpenIdConfiguration + ): any { + if (!this.tokenIsValid(token, configuration)) { + return {}; + } + + return this.getPartOfToken(token, 0, encoded); + } + + getPayloadFromToken( + token: string | undefined | null, + encoded: boolean, + configuration: OpenIdConfiguration | null + ): any { + if (!configuration) { + return {}; + } + + if (!this.tokenIsValid(token, configuration)) { + return {}; + } + + return this.getPartOfToken(token, 1, encoded); + } + + getSignatureFromToken( + token: string | undefined | null, + encoded: boolean, + configuration: OpenIdConfiguration + ): any { + if (!this.tokenIsValid(token, configuration)) { + return {}; + } + + return this.getPartOfToken(token, 2, encoded); + } + + private getPartOfToken(token: string, index: number, encoded: boolean): any { + const partOfToken = this.extractPartOfToken(token, index); + + if (encoded) { + return partOfToken; + } + + const result = this.urlBase64Decode(partOfToken); + + return JSON.parse(result); + } + + private urlBase64Decode(str: string): string { + let output = str.replace(/-/g, '+').replace(/_/g, '/'); + + switch (output.length % 4) { + case 0: + break; + case 2: + output += '=='; + break; + case 3: + output += '='; + break; + default: + throw Error('Illegal base64url string!'); + } + + const decoded = + typeof this.document.defaultView !== 'undefined' + ? this.document.defaultView?.atob(output) + : Buffer.from(output, 'base64').toString('binary'); + + if (!decoded) { + return ''; + } + + try { + // Going backwards: from byte stream, to percent-encoding, to original string. + return decodeURIComponent( + decoded + .split('') + .map( + (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + ) + .join('') + ); + } catch (err) { + return decoded; + } + } + + private tokenIsValid( + token: string | undefined | null, + configuration: OpenIdConfiguration + ): token is string { + if (!token) { + this.loggerService.logError( + configuration, + `token '${token}' is not valid --> token falsy` + ); + + return false; + } + + if (!(token as string).includes('.')) { + this.loggerService.logError( + configuration, + `token '${token}' is not valid --> no dots included` + ); + + return false; + } + + const parts = token.split('.'); + + if (parts.length !== PARTS_OF_TOKEN) { + this.loggerService.logError( + configuration, + `token '${token}' is not valid --> token has to have exactly ${ + PARTS_OF_TOKEN - 1 + } dots` + ); + + return false; + } + + return true; + } + + private extractPartOfToken(token: string, index: number): string { + return token.split('.')[index]; + } +} diff --git a/src/utils/url/current-url.service.spec.ts b/src/utils/url/current-url.service.spec.ts new file mode 100644 index 0000000..cd05d81 --- /dev/null +++ b/src/utils/url/current-url.service.spec.ts @@ -0,0 +1,78 @@ +import { DOCUMENT } from '../../dom'; +import { TestBed } from '@angular/core/testing'; +import { CurrentUrlService } from './current-url.service'; + +describe('CurrentUrlService with existing Url', () => { + let service: CurrentUrlService; + + const documentValue = { + defaultView: { location: 'http://my-url.com?state=my-state' }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: DOCUMENT, + useValue: documentValue, + }, + ], + }); + + service = TestBed.inject(CurrentUrlService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getCurrentUrl', () => { + it('returns the current URL', () => { + const currentUrl = service.getCurrentUrl(); + + expect(currentUrl).toBe('http://my-url.com?state=my-state'); + }); + }); + + describe('getStateParamFromCurrentUrl', () => { + it('returns null if there is no current URL', () => { + spyOn(service, 'getCurrentUrl').and.returnValue(null); + + const stateParam = service.getStateParamFromCurrentUrl(''); + + expect(stateParam).toBe(null); + }); + + it('returns the state param for the URL', () => { + const stateParam = service.getStateParamFromCurrentUrl(); + + expect(stateParam).toBe('my-state'); + }); + + it('returns the state param for the URL if one is passed', () => { + const stateParam = service.getStateParamFromCurrentUrl( + 'http://my-url.com?state=my-passed-state' + ); + + expect(stateParam).toBe('my-passed-state'); + }); + + it('returns the state param for the URL if one is passed as empty string', () => { + const stateParam = service.getStateParamFromCurrentUrl(''); + + expect(stateParam).toBe('my-state'); + }); + + it('returns the state param for the URL if one is passed as null', () => { + const stateParam = service.getStateParamFromCurrentUrl(undefined); + + expect(stateParam).toBe('my-state'); + }); + + it('returns the state param for the URL if one is passed as undefined', () => { + const stateParam = service.getStateParamFromCurrentUrl(undefined); + + expect(stateParam).toBe('my-state'); + }); + }); +}); diff --git a/src/utils/url/current-url.service.ts b/src/utils/url/current-url.service.ts new file mode 100644 index 0000000..59a70ab --- /dev/null +++ b/src/utils/url/current-url.service.ts @@ -0,0 +1,24 @@ +import { DOCUMENT } from '../../dom'; +import { inject, Injectable } from 'injection-js'; + +@Injectable() +export class CurrentUrlService { + private readonly document: Document = inject(DOCUMENT); + + getStateParamFromCurrentUrl(url?: string): string | null { + const currentUrl = url || this.getCurrentUrl(); + + if (!currentUrl) { + return null; + } + + const parsedUrl = new URL(currentUrl); + const urlParams = new URLSearchParams(parsedUrl.search); + + return urlParams.get('state'); + } + + getCurrentUrl(): string | null { + return this.document?.defaultView?.location.toString() ?? null; + } +} diff --git a/src/utils/url/uri-encoder.ts b/src/utils/url/uri-encoder.ts new file mode 100644 index 0000000..3fd2168 --- /dev/null +++ b/src/utils/url/uri-encoder.ts @@ -0,0 +1,19 @@ +import { HttpParameterCodec } from '@ngify/http'; + +export class UriEncoder implements HttpParameterCodec { + encodeKey(key: string): string { + return encodeURIComponent(key); + } + + encodeValue(value: string): string { + return encodeURIComponent(value); + } + + decodeKey(key: string): string { + return decodeURIComponent(key); + } + + decodeValue(value: string): string { + return decodeURIComponent(value); + } +} diff --git a/src/utils/url/url.service.spec.ts b/src/utils/url/url.service.spec.ts new file mode 100644 index 0000000..09ba88f --- /dev/null +++ b/src/utils/url/url.service.spec.ts @@ -0,0 +1,2092 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../../test/auto-mock'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { FlowsDataService } from '../../flows/flows-data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { JwtWindowCryptoService } from '../../validation/jwt-window-crypto.service'; +import { FlowHelper } from '../flowHelper/flow-helper.service'; +import { UrlService } from './url.service'; + +describe('UrlService Tests', () => { + let service: UrlService; + let flowHelper: FlowHelper; + let flowsDataService: FlowsDataService; + let jwtWindowCryptoService: JwtWindowCryptoService; + let storagePersistenceService: StoragePersistenceService; + let loggerService: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + UrlService, + mockProvider(LoggerService), + mockProvider(FlowsDataService), + FlowHelper, + mockProvider(StoragePersistenceService), + mockProvider(JwtWindowCryptoService), + ], + }); + }); + + beforeEach(() => { + service = TestBed.inject(UrlService); + loggerService = TestBed.inject(LoggerService); + flowHelper = TestBed.inject(FlowHelper); + flowsDataService = TestBed.inject(FlowsDataService); + jwtWindowCryptoService = TestBed.inject(JwtWindowCryptoService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getUrlWithoutQueryParameters', () => { + it('should return a new instance of the passed URL without any query parameters', () => { + const url = new URL('https://any.url'); + + const params = [ + { key: 'doot', value: 'boop' }, + { key: 'blep', value: 'blep' }, + ]; + + params.forEach((p) => { + url.searchParams.set(p.key, p.value); + }); + + const sut = service.getUrlWithoutQueryParameters(url); + + params.forEach((p) => { + expect(sut.searchParams.has(p.key)).toBeFalse(); + }); + }); + }); + + describe('queryParametersExist', () => { + const expected = new URLSearchParams(); + + const params = [ + { key: 'doot', value: 'boop' }, + { key: 'blep', value: 'blep' }, + ]; + + params.forEach((p) => { + expected.set(p.key, p.value); + }); + + const matchingUrls = [ + new URL('https://any.url?doot=boop&blep=blep'), + new URL('https://any.url?doot=boop&blep=blep&woop=doot'), + ]; + + const nonMatchingUrls = [ + new URL('https://any.url?doot=boop'), + new URL('https://any.url?blep=blep&woop=doot'), + ]; + + matchingUrls.forEach((mu) => { + it(`should return true for ${mu.toString()}`, () => { + expect( + service.queryParametersExist(expected, mu.searchParams) + ).toBeTrue(); + }); + }); + + nonMatchingUrls.forEach((nmu) => { + it(`should return false for ${nmu.toString()}`, () => { + expect( + service.queryParametersExist(expected, nmu.searchParams) + ).toBeFalse(); + }); + }); + }); + + describe('isCallbackFromSts', () => { + it(`should return false if config says to check redirect URI, and it doesn't match`, () => { + const nonMatchingUrls = [ + { + url: 'https://the-redirect.url', + config: { + redirectUrl: 'https://the-redirect.url?with=parameter', + checkRedirectUrlWhenCheckingIfIsCallback: true, + }, + }, + { + url: 'https://the-redirect.url?wrong=parameter', + config: { + redirectUrl: 'https://the-redirect.url?with=parameter', + checkRedirectUrlWhenCheckingIfIsCallback: true, + }, + }, + { + url: 'https://not-the-redirect.url', + config: { + redirectUrl: 'https://the-redirect.url', + checkRedirectUrlWhenCheckingIfIsCallback: true, + }, + }, + ]; + + nonMatchingUrls.forEach((nmu) => { + expect(service.isCallbackFromSts(nmu.url, nmu.config)).toBeFalse(); + }); + }); + + const testingValues = [ + { param: 'code', isCallbackFromSts: true }, + { param: 'state', isCallbackFromSts: true }, + { param: 'token', isCallbackFromSts: true }, + { param: 'id_token', isCallbackFromSts: true }, + { param: 'some_param', isCallbackFromSts: false }, + ]; + + testingValues.forEach(({ param, isCallbackFromSts }) => { + it(`should return ${isCallbackFromSts} when param is ${param}`, () => { + const result = service.isCallbackFromSts( + `https://any.url/?${param}=anyvalue` + ); + + expect(result).toBe(isCallbackFromSts); + }); + }); + }); + + describe('getUrlParameter', () => { + it('returns empty string when there is no urlToCheck', () => { + const result = service.getUrlParameter('', 'code'); + + expect(result).toBe(''); + }); + + it('returns empty string when there is no name', () => { + const result = service.getUrlParameter('url', ''); + + expect(result).toBe(''); + }); + + it('returns empty string when name is not a uri', () => { + const result = service.getUrlParameter('url', 'anything'); + + expect(result).toBe(''); + }); + + it('parses Url correctly with hash in the end', () => { + const urlToCheck = + 'https://www.example.com/signin?code=thisisacode&state=0000.1234.000#'; + const code = service.getUrlParameter(urlToCheck, 'code'); + const state = service.getUrlParameter(urlToCheck, 'state'); + + expect(code).toBe('thisisacode'); + expect(state).toBe('0000.1234.000'); + }); + + it('parses url with special chars in param and hash in the end', () => { + const urlToCheck = + 'https://www.example.com/signin?code=thisisa$-_.+!*(),code&state=0000.1234.000#'; + const code = service.getUrlParameter(urlToCheck, 'code'); + const state = service.getUrlParameter(urlToCheck, 'state'); + + expect(code).toBe('thisisa$-_.+!*(),code'); + expect(state).toBe('0000.1234.000'); + }); + + it('parses Url correctly with number&delimiter in params', () => { + const urlToCheck = + 'https://www.example.com/signin?code=thisisacode&state=0000.1234.000'; + const code = service.getUrlParameter(urlToCheck, 'code'); + const state = service.getUrlParameter(urlToCheck, 'state'); + + expect(code).toBe('thisisacode'); + expect(state).toBe('0000.1234.000'); + }); + + it('gets correct param if params divided vith slash', () => { + const urlToCheck = + 'https://www.example.com/signin?state=0000.1234.000&ui_locales=de&code=thisisacode#lang=de'; + const code = service.getUrlParameter(urlToCheck, 'code'); + const state = service.getUrlParameter(urlToCheck, 'state'); + + expect(code).toBe('thisisacode'); + expect(state).toBe('0000.1234.000'); + }); + + it('gets correct params when response_mode=fragment', () => { + // Test url taken from an example in the RFC: https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2 + const urlToCheck = + 'http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA&state=xyz&token_type=example&expires_in=3600'; + const accessToken = service.getUrlParameter(urlToCheck, 'access_token'); + const state = service.getUrlParameter(urlToCheck, 'state'); + const tokenType = service.getUrlParameter(urlToCheck, 'token_type'); + const expiresIn = service.getUrlParameter(urlToCheck, 'expires_in'); + + expect(accessToken).toBe('2YotnFZFEjr1zCsicMWpAA'); + expect(state).toBe('xyz'); + expect(tokenType).toBe('example'); + expect(expiresIn).toBe('3600'); + }); + + it('gets correct params when square brackets are present', () => { + const urlToCheck = + 'http://example.com/cb#state=abc[&code=abc&arr[]=1&some_param=abc]&arr[]=2&arr[]=3'; + const state = service.getUrlParameter(urlToCheck, 'state'); + const code = service.getUrlParameter(urlToCheck, 'code'); + const someParam = service.getUrlParameter(urlToCheck, 'some_param'); + const array = service.getUrlParameter(urlToCheck, 'arr[]'); + + expect(state).toBe('abc['); + expect(code).toBe('abc'); + expect(someParam).toBe('abc]'); + expect(['1', '2', '3']).toContain(array); + }); + }); + + describe('createAuthorizeUrl', () => { + it('returns empty string when no authoizationendpoint given -> wellKnownEndpoints null', () => { + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + 'https://localhost:44386', + 'nonce', + 'state' + ); + + expect(value).toEqual(''); + }); + + it('returns empty string when no authoizationendpoint given -> configurationProvider null', () => { + (service as any).configurationProvider = null; + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + 'https://localhost:44386', + 'nonce', + 'state' + ); + + expect(value).toEqual(''); + }); + + it('returns empty string when clientId is null', () => { + const config = { configId: 'configId1', clientId: '' }; + const authorizationEndpoint = 'authorizationEndpoint'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + 'https://localhost:44386', + 'nonce', + 'state', + config + ); + + expect(value).toEqual(''); + }); + + it('returns empty string when responseType is null', () => { + const config = { + configId: 'configId1', + clientId: 'something', + responseType: undefined, + }; + const authorizationEndpoint = 'authorizationEndpoint'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + 'https://localhost:44386', + 'nonce', + 'state', + config + ); + + expect(value).toEqual(''); + }); + + it('returns empty string when scope is null', () => { + const config = { + configId: 'configId1', + clientId: 'something', + responseType: 'responsetype', + scope: undefined, + }; + const authorizationEndpoint = 'authorizationEndpoint'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + 'https://localhost:44386', + 'nonce', + 'state', + config + ); + + expect(value).toEqual(''); + }); + + it('createAuthorizeUrl with code flow and codeChallenge adds "code_challenge" and "code_challenge_method" param', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'code'; + config.scope = 'openid email profile'; + config.redirectUrl = 'https://localhost:44386'; + config.customParamsAuthRequest = { + testcustom: 'customvalue', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint: 'http://example' }); + + const value = (service as any).createAuthorizeUrl( + 'codeChallenge', // Code Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=code' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&code_challenge=codeChallenge&code_challenge_method=S256' + + '&testcustom=customvalue'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl with prompt adds prompt value', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.configId = 'configId1'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + 'myprompt' + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&prompt=myprompt'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl with prompt and custom values adds prompt value and custom values', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.configId = 'configId1'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + 'myprompt', + { to: 'add', as: 'well' } + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&to=add&as=well' + + '&prompt=myprompt'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl with hdParam adds hdparam value', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.hdParam = 'myHdParam'; + config.configId = 'configId1'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&hd=myHdParam'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl with custom value', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.configId = 'configId1'; + + config.customParamsAuthRequest = { + testcustom: 'customvalue', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&testcustom=customvalue'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl with custom values', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.configId = 'configId1'; + + config.customParamsAuthRequest = { + t4: 'ABC abc 123', + t3: '#', + t2: '-_.!~*()', + t1: ';,/?:@&=+$', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state&t4=ABC%20abc%20123&t3=%23&t2=-_.!~*()&t1=%3B%2C%2F%3F%3A%40%26%3D%2B%24'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl creates URL with with custom values and dynamic custom values', () => { + const config = { + authority: 'https://localhost:5001', + redirectUrl: 'https://localhost:44386', + clientId: + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + responseType: 'id_token token', + scope: 'openid email profile', + configId: 'configId1', + customParamsAuthRequest: { + t4: 'ABC abc 123', + t3: '#', + t2: '-_.!~*()', + t1: ';,/?:@&=+$', + }, + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + null, + { to: 'add', as: 'well' } + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&t4=ABC%20abc%20123&t3=%23&t2=-_.!~*()&t1=%3B%2C%2F%3F%3A%40%26%3D%2B%24' + + '&to=add&as=well'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl creates URL with custom values equals null and dynamic custom values', () => { + const config = { + authority: 'https://localhost:5001', + redirectUrl: 'https://localhost:44386', + clientId: + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + responseType: 'id_token token', + scope: 'openid email profile', + customParamsAuthRequest: undefined, + configId: 'configId1', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + null, + { to: 'add', as: 'well' } + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&to=add&as=well'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl creates URL with custom values not given and dynamic custom values', () => { + const config = { + authority: 'https://localhost:5001', + redirectUrl: 'https://localhost:44386', + clientId: + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + responseType: 'id_token token', + scope: 'openid email profile', + configId: 'configId1', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + null, + { to: 'add', as: 'well' } + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&to=add&as=well'; + + expect(value).toEqual(expectValue); + }); + + // https://docs.microsoft.com/en-us/azure/active-directory-b2c/active-directory-b2c-reference-oidc + it('createAuthorizeUrl with custom URL like active-directory-b2c', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = 'myid'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: + 'https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_sign_in', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_sign_in' + + '&client_id=myid' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state'; + + expect(value).toEqual(expectValue); + }); + + it('createAuthorizeUrl default', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.configId = 'configId1'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'http://example', + }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=id_token%20token' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state'; + + expect(value).toEqual(expectValue); + }); + + it('should add the prompt only once even if it is configured AND passed with `none` in silent renew case, taking the passed one', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'code'; + config.scope = 'openid email profile'; + config.redirectUrl = 'https://localhost:44386'; + + config.customParamsAuthRequest = { + prompt: 'select_account', + }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint: 'http://example' }); + + const value = (service as any).createAuthorizeUrl( + '', // Implicit Flow + config.redirectUrl, + 'nonce', + 'state', + config, + 'somePrompt' + ); + + const expectValue = + 'http://example?client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + + '&redirect_uri=https%3A%2F%2Flocalhost%3A44386' + + '&response_type=code' + + '&scope=openid%20email%20profile' + + '&nonce=nonce' + + '&state=state' + + '&code_challenge=' + + '&code_challenge_method=S256' + + '&prompt=somePrompt'; + + expect(value).toEqual(expectValue); + }); + }); + + describe('createRevocationEndpointBodyAccessToken', () => { + it('createRevocationBody access_token default', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.postLogoutRedirectUri = 'https://localhost:44386/Unauthorized'; + + const revocationEndpoint = 'http://example?cod=ddd'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + revocationEndpoint, + }); + + const value = service.createRevocationEndpointBodyAccessToken( + 'mytoken', + config + ); + const expectValue = + 'client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com&token=mytoken&token_type_hint=access_token'; + + expect(value).toEqual(expectValue); + }); + + it('createRevocationEndpointBodyAccessToken returns null when no clientId is given', () => { + const config = { + authority: 'https://localhost:5001', + clientId: '', + } as OpenIdConfiguration; + const value = service.createRevocationEndpointBodyAccessToken( + 'mytoken', + config + ); + + expect(value).toBeNull(); + }); + }); + + describe('createRevocationEndpointBodyRefreshToken', () => { + it('createRevocationBody refresh_token default', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.postLogoutRedirectUri = 'https://localhost:44386/Unauthorized'; + + const revocationEndpoint = 'http://example?cod=ddd'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + revocationEndpoint, + }); + + const value = service.createRevocationEndpointBodyRefreshToken( + 'mytoken', + config + ); + const expectValue = + 'client_id=188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com&token=mytoken&token_type_hint=refresh_token'; + + expect(value).toEqual(expectValue); + }); + + it('createRevocationEndpointBodyRefreshToken returns null when no clientId is given', () => { + const config = { + authority: 'https://localhost:5001', + clientId: undefined, + } as OpenIdConfiguration; + const value = service.createRevocationEndpointBodyRefreshToken( + 'mytoken', + config + ); + + expect(value).toBeNull(); + }); + }); + + describe('getRevocationEndpointUrl', () => { + it('getRevocationEndpointUrl with params', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.postLogoutRedirectUri = 'https://localhost:44386/Unauthorized'; + + const revocationEndpoint = 'http://example?cod=ddd'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + revocationEndpoint, + }); + + const value = service.getRevocationEndpointUrl(config); + + const expectValue = 'http://example'; + + expect(value).toEqual(expectValue); + }); + + it('getRevocationEndpointUrl default', () => { + const config = { + authority: 'https://localhost:5001', + } as OpenIdConfiguration; + + config.redirectUrl = 'https://localhost:44386'; + config.clientId = + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com'; + config.responseType = 'id_token token'; + config.scope = 'openid email profile'; + config.postLogoutRedirectUri = 'https://localhost:44386/Unauthorized'; + + const revocationEndpoint = 'http://example'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + revocationEndpoint, + }); + + const value = service.getRevocationEndpointUrl(config); + + const expectValue = 'http://example'; + + expect(value).toEqual(expectValue); + }); + + it('getRevocationEndpointUrl returns null when there is not revociationendpoint given', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', {}) + .and.returnValue({ + revocationEndpoint: null, + }); + const value = service.getRevocationEndpointUrl({}); + + expect(value).toBeNull(); + }); + + it('getRevocationEndpointUrl returns null when there is no wellKnownEndpoints given', () => { + const value = service.getRevocationEndpointUrl({}); + + expect(value).toBeNull(); + }); + }); + + describe('getRedirectUrl', () => { + it('returns configured redirectUrl', () => { + const config = { configId: 'configId1', redirectUrl: 'one-url' }; + const url = (service as any).getRedirectUrl(config); + + expect(url).toEqual('one-url'); + }); + + it('returns redefined redirectUrl in AuthOptions', () => { + const config = { configId: 'configId1', redirectUrl: 'one-url' }; + const url = (service as any).getRedirectUrl(config, { + redirectUrl: 'other-url', + }); + + expect(url).toEqual('other-url'); + }); + }); + + describe('getAuthorizeUrl', () => { + it('returns null if no config is given', waitForAsync(() => { + service.getAuthorizeUrl(null).subscribe((url) => { + expect(url).toBeNull(); + }); + })); + + it('returns null if current flow is code flow and no redirect url is defined', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + + service.getAuthorizeUrl({ configId: 'configId1' }).subscribe((result) => { + expect(result).toBeNull(); + }); + })); + + it('returns empty string if current flow is code flow, config disabled pkce and there is a redirecturl', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const config = { + configId: 'configId1', + disablePkce: true, + redirectUrl: 'some-redirectUrl', + } as OpenIdConfiguration; + + service.getAuthorizeUrl(config).subscribe((result) => { + expect(result).toBe(''); + }); + })); + + it('returns url if current flow is code flow, config disabled pkce, there is a redirecturl and awkep are given', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const config = { + configId: 'configId1', + disablePkce: false, + redirectUrl: 'some-redirectUrl', + clientId: 'some-clientId', + responseType: 'testResponseType', + scope: 'testScope', + hdParam: undefined, + customParamsAuthRequest: undefined, + } as OpenIdConfiguration; + + const authorizationEndpoint = 'authorizationEndpoint'; + + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of('some-code-challenge') + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + service.getAuthorizeUrl(config).subscribe((result) => { + expect(result).toBe( + 'authorizationEndpoint?client_id=some-clientId&redirect_uri=some-redirectUrl&response_type=testResponseType&scope=testScope&nonce=undefined&state=undefined&code_challenge=some-code-challenge&code_challenge_method=S256' + ); + }); + })); + + it('calls createUrlImplicitFlowAuthorize if current flow is NOT code flow', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + const spyCreateUrlCodeFlowAuthorize = spyOn( + service as any, + 'createUrlCodeFlowAuthorize' + ); + const spyCreateUrlImplicitFlowAuthorize = spyOn( + service as any, + 'createUrlImplicitFlowAuthorize' + ); + + service.getAuthorizeUrl({ configId: 'configId1' }).subscribe(() => { + expect(spyCreateUrlCodeFlowAuthorize).not.toHaveBeenCalled(); + expect(spyCreateUrlImplicitFlowAuthorize).toHaveBeenCalled(); + }); + })); + + it('return empty string if flow is not code flow and createUrlImplicitFlowAuthorize returns falsy', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + const spy = spyOn( + service as any, + 'createUrlImplicitFlowAuthorize' + ).and.returnValue(''); + const resultObs$ = service.getAuthorizeUrl({ configId: 'configId1' }); + + resultObs$.subscribe((result) => { + expect(spy).toHaveBeenCalled(); + expect(result).toBe(''); + }); + })); + }); + + describe('getRefreshSessionSilentRenewUrl', () => { + it('calls createUrlCodeFlowWithSilentRenew if current flow is code flow', () => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(true); + const spy = spyOn(service as any, 'createUrlCodeFlowWithSilentRenew'); + + service.getRefreshSessionSilentRenewUrl({ configId: 'configId1' }); + expect(spy).toHaveBeenCalled(); + }); + + it('calls createUrlImplicitFlowWithSilentRenew if current flow is NOT code flow', () => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + const spyCreateUrlCodeFlowWithSilentRenew = spyOn( + service as any, + 'createUrlCodeFlowWithSilentRenew' + ); + const spyCreateUrlImplicitFlowWithSilentRenew = spyOn( + service as any, + 'createUrlImplicitFlowWithSilentRenew' + ); + + service.getRefreshSessionSilentRenewUrl({ configId: 'configId1' }); + expect(spyCreateUrlCodeFlowWithSilentRenew).not.toHaveBeenCalled(); + expect(spyCreateUrlImplicitFlowWithSilentRenew).toHaveBeenCalled(); + }); + + it('return empty string if flow is not code flow and createUrlImplicitFlowWithSilentRenew returns falsy', waitForAsync(() => { + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + const spy = spyOn( + service as any, + 'createUrlImplicitFlowWithSilentRenew' + ).and.returnValue(''); + const resultObs$ = service.getRefreshSessionSilentRenewUrl({ + configId: 'configId1', + }); + + resultObs$.subscribe((result) => { + expect(spy).toHaveBeenCalled(); + expect(result).toBe(''); + }); + })); + }); + + describe('createBodyForCodeFlowCodeRequest', () => { + it('returns null if no code verifier is set', () => { + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(null); + const result = service.createBodyForCodeFlowCodeRequest( + 'notRelevantParam', + { configId: 'configId1' } + ); + + expect(result).toBeNull(); + }); + + it('returns null if no clientId is set', () => { + const codeVerifier = 'codeverifier'; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(codeVerifier); + const clientId = ''; + const result = service.createBodyForCodeFlowCodeRequest( + 'notRelevantParam', + { clientId } + ); + + expect(result).toBeNull(); + }); + + it('returns null if silentrenewRunning is false and redirectUrl is falsy', () => { + const codeVerifier = 'codeverifier'; + const code = 'code'; + const redirectUrl = ''; + const clientId = 'clientId'; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(codeVerifier); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + + const result = service.createBodyForCodeFlowCodeRequest(code, { + clientId, + redirectUrl, + }); + + expect(result).toBeNull(); + }); + + it('returns correctUrl with silentrenewRunning is false', () => { + const codeVerifier = 'codeverifier'; + const code = 'code'; + const redirectUrl = 'redirectUrl'; + const clientId = 'clientId'; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(codeVerifier); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false); + + const result = service.createBodyForCodeFlowCodeRequest(code, { + clientId, + redirectUrl, + }); + const expected = `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&code=${code}&redirect_uri=${redirectUrl}`; + + expect(result).toBe(expected); + }); + + it('returns correctUrl with silentrenewRunning is true', () => { + const codeVerifier = 'codeverifier'; + const code = 'code'; + const silentRenewUrl = 'silentRenewUrl'; + const clientId = 'clientId'; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(codeVerifier); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + + const result = service.createBodyForCodeFlowCodeRequest(code, { + clientId, + silentRenewUrl, + }); + const expected = `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&code=${code}&redirect_uri=${silentRenewUrl}`; + + expect(result).toBe(expected); + }); + + it('returns correctUrl when customTokenParams are provided', () => { + const codeVerifier = 'codeverifier'; + const code = 'code'; + const silentRenewUrl = 'silentRenewUrl'; + const clientId = 'clientId'; + const customTokenParams = { foo: 'bar' }; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(codeVerifier); + spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true); + + const result = service.createBodyForCodeFlowCodeRequest( + code, + { clientId, silentRenewUrl }, + customTokenParams + ); + const expected = `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&code=${code}&foo=bar&redirect_uri=${silentRenewUrl}`; + + expect(result).toBe(expected); + }); + + it('returns null if pkce is disabled and no code verifier is given', () => { + const code = 'code'; + const customTokenParams = { foo: 'bar' }; + const config = { + clientId: 'clientId', + disablePkce: false, + }; + + spyOn(flowsDataService, 'getCodeVerifier').and.returnValue(null); + + const loggerspy = spyOn(loggerService, 'logError'); + const result = service.createBodyForCodeFlowCodeRequest( + code, + config, + customTokenParams + ); + + expect(result).toBe(null); + expect(loggerspy).toHaveBeenCalledOnceWith( + config, + 'CodeVerifier is not set ', + null + ); + }); + }); + + describe('createBodyForCodeFlowRefreshTokensRequest', () => { + it('returns correct URL', () => { + const clientId = 'clientId'; + const refreshToken = 'refreshToken'; + const result = service.createBodyForCodeFlowRefreshTokensRequest( + refreshToken, + { clientId } + ); + + expect(result).toBe( + `grant_type=refresh_token&client_id=${clientId}&refresh_token=${refreshToken}` + ); + }); + + it('returns correct URL with custom params if custom params are passed', () => { + const clientId = 'clientId'; + const refreshToken = 'refreshToken'; + const result = service.createBodyForCodeFlowRefreshTokensRequest( + refreshToken, + { clientId }, + { any: 'thing' } + ); + + expect(result).toBe( + `grant_type=refresh_token&client_id=${clientId}&refresh_token=${refreshToken}&any=thing` + ); + }); + + it('returns null if clientId is falsy', () => { + const clientId = ''; + const refreshToken = 'refreshToken'; + const result = service.createBodyForCodeFlowRefreshTokensRequest( + refreshToken, + { clientId } + ); + + expect(result).toBe(null); + }); + }); + + describe('createBodyForParCodeFlowRequest', () => { + it('returns null redirectUrl is falsy', waitForAsync(() => { + const resultObs$ = service.createBodyForParCodeFlowRequest({ + redirectUrl: '', + }); + + resultObs$.subscribe((result) => { + expect(result).toBe(null); + }); + })); + + it('returns basic URL with no extras if properties are given', waitForAsync(() => { + const config = { + clientId: 'testClientId', + responseType: 'testResponseType', + scope: 'testScope', + hdParam: undefined, + customParamsAuthRequest: undefined, + redirectUrl: 'testRedirectUrl', + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('testState'); + spyOn(flowsDataService, 'createNonce').and.returnValue('testNonce'); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + 'testCodeVerifier' + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of('testCodeChallenge') + ); + + const resultObs$ = service.createBodyForParCodeFlowRequest(config); + + resultObs$.subscribe((result) => { + expect(result).toBe( + `client_id=testClientId&redirect_uri=testRedirectUrl&response_type=testResponseType&scope=testScope&nonce=testNonce&state=testState&code_challenge=testCodeChallenge&code_challenge_method=S256` + ); + }); + })); + + it('returns basic URL with hdParam if properties are given', waitForAsync(() => { + const config = { + clientId: 'testClientId', + responseType: 'testResponseType', + scope: 'testScope', + hdParam: 'testHdParam', + customParamsAuthRequest: undefined, + redirectUrl: 'testRedirectUrl', + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('testState'); + spyOn(flowsDataService, 'createNonce').and.returnValue('testNonce'); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + 'testCodeVerifier' + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of('testCodeChallenge') + ); + + const resultObs$ = service.createBodyForParCodeFlowRequest(config); + + resultObs$.subscribe((result) => { + expect(result).toBe( + `client_id=testClientId&redirect_uri=testRedirectUrl&response_type=testResponseType&scope=testScope&nonce=testNonce&state=testState&code_challenge=testCodeChallenge&code_challenge_method=S256&hd=testHdParam` + ); + }); + })); + + it('returns basic URL with hdParam and custom params if properties are given', waitForAsync(() => { + const config = { + clientId: 'testClientId', + responseType: 'testResponseType', + scope: 'testScope', + hdParam: 'testHdParam', + customParamsAuthRequest: { any: 'thing' }, + redirectUrl: 'testRedirectUrl', + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('testState'); + spyOn(flowsDataService, 'createNonce').and.returnValue('testNonce'); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + 'testCodeVerifier' + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of('testCodeChallenge') + ); + + const resultObs$ = service.createBodyForParCodeFlowRequest(config); + + resultObs$.subscribe((result) => { + expect(result).toBe( + `client_id=testClientId&redirect_uri=testRedirectUrl&response_type=testResponseType&scope=testScope&nonce=testNonce&state=testState&code_challenge=testCodeChallenge&code_challenge_method=S256&hd=testHdParam&any=thing` + ); + }); + })); + + it('returns basic URL with hdParam and custom params and passed cutom params if properties are given', waitForAsync(() => { + const config = { + clientId: 'testClientId', + responseType: 'testResponseType', + scope: 'testScope', + hdParam: 'testHdParam', + customParamsAuthRequest: { any: 'thing' }, + redirectUrl: 'testRedirectUrl', + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue('testState'); + spyOn(flowsDataService, 'createNonce').and.returnValue('testNonce'); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + 'testCodeVerifier' + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of('testCodeChallenge') + ); + + const resultObs$ = service.createBodyForParCodeFlowRequest(config, { + customParams: { + any: 'otherThing', + }, + }); + + resultObs$.subscribe((result) => { + expect(result).toBe( + `client_id=testClientId&redirect_uri=testRedirectUrl&response_type=testResponseType&scope=testScope&nonce=testNonce&state=testState&code_challenge=testCodeChallenge&code_challenge_method=S256&hd=testHdParam&any=thing&any=otherThing` + ); + }); + })); + }); + + describe('createUrlImplicitFlowWithSilentRenew', () => { + it('returns null if silentrenewUrl is falsy', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = null; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + const config = { + silentRenewUrl, + }; + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowWithSilentRenew(config); + + expect(result).toBeNull(); + }); + + it('returns correct URL if wellknownendpoints are given', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = 'http://any-url.com'; + const authorizationEndpoint = 'authorizationEndpoint'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const scope = 'testScope'; + const config = { + silentRenewUrl, + clientId, + responseType, + scope, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint, + }); + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowWithSilentRenew(config); + + expect(result).toBe( + `authorizationEndpoint?client_id=${clientId}&redirect_uri=http%3A%2F%2Fany-url.com&response_type=${responseType}&scope=${scope}&nonce=${nonce}&state=${state}&prompt=none` + ); + }); + + it('returns correct url if wellknownendpoints are not given', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = 'http://any-url.com'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const config = { + silentRenewUrl, + clientId, + responseType, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowWithSilentRenew(config); + + expect(result).toBe(null); + }); + }); + + describe('createUrlCodeFlowWithSilentRenew', () => { + it('returns empty string if silentrenewUrl is falsy', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = null; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge '; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + + const config = { + silentRenewUrl, + }; + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowWithSilentRenew(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBe(''); + }); + })); + + it('returns correct URL if wellknownendpoints are given', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = 'http://any-url.com'; + const authorizationEndpoint = 'authorizationEndpoint'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge '; + const scope = 'testScope'; + const config = { + silentRenewUrl, + clientId, + responseType, + scope, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowWithSilentRenew(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBe( + `authorizationEndpoint?client_id=${clientId}&redirect_uri=http%3A%2F%2Fany-url.com&response_type=${responseType}&scope=${scope}&nonce=${nonce}&state=${state}&prompt=none` + ); + }); + })); + + it('returns empty string if no wellknownendpoints are given', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const silentRenewUrl = 'http://any-url.com'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge '; + const config = { + silentRenewUrl, + clientId, + responseType, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowWithSilentRenew(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBe(''); + }); + })); + }); + + describe('createUrlImplicitFlowAuthorize', () => { + it('returns correct URL if wellknownendpoints are given', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const redirectUrl = 'http://any-url.com'; + const authorizationEndpoint = 'authorizationEndpoint'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const scope = 'testScope'; + const config = { + redirectUrl, + clientId, + responseType, + scope, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowAuthorize(config); + + expect(result).toBe( + `authorizationEndpoint?client_id=clientId&redirect_uri=http%3A%2F%2Fany-url.com&response_type=${responseType}&scope=${scope}&nonce=${nonce}&state=${state}` + ); + }); + + it('returns empty string if no wellknownendpoints are given', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const redirectUrl = 'http://any-url.com'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const config = { redirectUrl, clientId, responseType }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowAuthorize(config); + + expect(result).toBe(null); + }); + + it('returns null if there is nor redirecturl', () => { + const state = 'testState'; + const nonce = 'testNonce'; + const redirectUrl = ''; + const clientId = 'clientId'; + const responseType = 'responseType'; + const config = { redirectUrl, clientId, responseType }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + + const serviceAsAny = service as any; + + const result = serviceAsAny.createUrlImplicitFlowAuthorize(config); + + expect(result).toBe(null); + }); + }); + + describe('createUrlCodeFlowAuthorize', () => { + it('returns null if redirectUrl is falsy', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const redirectUrl = null; + const config = { + redirectUrl, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowAuthorize(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBeNull(); + }); + })); + + it('returns correct URL if wellknownendpoints are given', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const scope = 'testScope'; + const redirectUrl = 'http://any-url.com'; + const authorizationEndpoint = 'authorizationEndpoint'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge '; + const config = { + redirectUrl, + clientId, + responseType, + scope, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowAuthorize(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBe( + `authorizationEndpoint?client_id=clientId&redirect_uri=http%3A%2F%2Fany-url.com&response_type=${responseType}&scope=${scope}&nonce=${nonce}&state=${state}` + ); + }); + })); + + it('returns correct URL if wellknownendpoints and custom params are given', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const scope = 'testScope'; + const redirectUrl = 'http://any-url.com'; + const authorizationEndpoint = 'authorizationEndpoint'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge'; + const configId = 'configId1'; + const config = { + redirectUrl, + clientId, + responseType, + scope, + configId, + }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ authorizationEndpoint }); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowAuthorize(config, { + customParams: { to: 'add', as: 'well' }, + }); + + resultObs$.subscribe((result: any) => { + expect(result).toBe( + `authorizationEndpoint?client_id=clientId&redirect_uri=http%3A%2F%2Fany-url.com` + + `&response_type=${responseType}&scope=${scope}&nonce=${nonce}&state=${state}&to=add&as=well` + ); + }); + })); + + it('returns empty string if no wellknownendpoints are given', waitForAsync(() => { + const state = 'testState'; + const nonce = 'testNonce'; + const redirectUrl = 'http://any-url.com'; + const clientId = 'clientId'; + const responseType = 'responseType'; + const codeVerifier = 'codeVerifier'; + const codeChallenge = 'codeChallenge '; + const config = { redirectUrl, clientId, responseType }; + + spyOn( + flowsDataService, + 'getExistingOrCreateAuthStateControl' + ).and.returnValue(state); + spyOn(flowsDataService, 'createNonce').and.returnValue(nonce); + spyOn(flowsDataService, 'createCodeVerifier').and.returnValue( + codeVerifier + ); + spyOn(jwtWindowCryptoService, 'generateCodeChallenge').and.returnValue( + of(codeChallenge) + ); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(null); + + const serviceAsAny = service as any; + + const resultObs$ = serviceAsAny.createUrlCodeFlowAuthorize(config); + + resultObs$.subscribe((result: any) => { + expect(result).toBe(''); + }); + })); + }); + + describe('getEndSessionUrl', () => { + it('returns null if no config given', () => { + const value = service.getEndSessionUrl(null); + + expect(value).toBeNull(); + }); + + it('create URL when all parameters given', () => { + //Arrange + const config = { + postLogoutRedirectUri: 'https://localhost:44386/Unauthorized', + } as OpenIdConfiguration; + + spyOn(storagePersistenceService, 'getIdToken').and.returnValue('mytoken'); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + endSessionEndpoint: 'http://example', + }); + + // Act + const value = service.getEndSessionUrl(config); + + // Assert + const expectValue = + 'http://example?id_token_hint=mytoken&post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A44386%2FUnauthorized'; + + expect(value).toEqual(expectValue); + }); + + it('create URL when all parameters given but no idTokenHint', () => { + // Arrange + const config = { + postLogoutRedirectUri: 'https://localhost:44386/Unauthorized', + } as OpenIdConfiguration; + + spyOn(storagePersistenceService, 'getIdToken').and.returnValue(''); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + endSessionEndpoint: 'http://example', + }); + + // Act + const value = service.getEndSessionUrl(config); + + // Assert + const expectValue = + 'http://example?post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A44386%2FUnauthorized'; + + expect(value).toEqual(expectValue); + }); + + it('create URL when all parameters and customParamsEndSession given', () => { + // Arrange + const config = { + postLogoutRedirectUri: 'https://localhost:44386/Unauthorized', + } as OpenIdConfiguration; + + spyOn(storagePersistenceService, 'getIdToken').and.returnValue('mytoken'); + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + endSessionEndpoint: 'http://example', + }); + + // Act + const value = service.getEndSessionUrl(config, { param: 'to-add' }); + + // Assert + const expectValue = + 'http://example?id_token_hint=mytoken&post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A44386%2FUnauthorized¶m=to-add'; + + expect(value).toEqual(expectValue); + }); + + it('with azure-ad-b2c policy parameter', () => { + // Arrange + const config = { + postLogoutRedirectUri: 'https://localhost:44386/Unauthorized', + } as OpenIdConfiguration; + const endSessionEndpoint = + 'https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/logout?p=b2c_1_sign_in'; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + endSessionEndpoint, + }); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue( + 'UzI1NiIsImtpZCI6Il' + ); + + // Act + const value = service.getEndSessionUrl(config); + + // Assert + const expectValue = + 'https://login.microsoftonline.com/fabrikamb2c.onmicrosoft.com/oauth2/v2.0/logout?p=b2c_1_sign_in' + + '&id_token_hint=UzI1NiIsImtpZCI6Il' + + '&post_logout_redirect_uri=https%3A%2F%2Flocalhost%3A44386%2FUnauthorized'; + + expect(value).toEqual(expectValue); + }); + + it('create URL without postLogoutRedirectUri when not given', () => { + const config = { + postLogoutRedirectUri: '', + } as OpenIdConfiguration; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + endSessionEndpoint: 'http://example', + }); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue('mytoken'); + + // Act + const value = service.getEndSessionUrl(config); + + // Assert + const expectValue = 'http://example?id_token_hint=mytoken'; + + expect(value).toEqual(expectValue); + }); + + it('returns null if no wellknownEndpoints.endSessionEndpoint given', () => { + // Arrange + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', {}) + .and.returnValue({ + endSessionEndpoint: null, + }); + spyOn(storagePersistenceService, 'getIdToken').and.returnValue('mytoken'); + + // Act + const value = service.getEndSessionUrl({}); + + // Assert + expect(value).toEqual(null); + }); + + it('returns auth0 format URL if authority ends with .auth0', () => { + // Arrange + const config = { + authority: 'something.auth0.com', + clientId: 'someClientId', + postLogoutRedirectUri: 'https://localhost:1234/unauthorized', + }; + + // Act + const value = service.getEndSessionUrl(config); + + // Assert + const expectValue = `something.auth0.com/v2/logout?client_id=someClientId&returnTo=https://localhost:1234/unauthorized`; + + expect(value).toEqual(expectValue); + }); + }); + + describe('getAuthorizeParUrl', () => { + it('returns null if authWellKnownEndPoints is undefined', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue(null); + + const result = service.getAuthorizeParUrl('', { configId: 'configId1' }); + + expect(result).toBe(null); + }); + + it('returns null if authWellKnownEndPoints-authorizationEndpoint is undefined', () => { + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', { configId: 'configId1' }) + .and.returnValue({ + notAuthorizationEndpoint: 'anything', + }); + + const result = service.getAuthorizeParUrl('', { configId: 'configId1' }); + + expect(result).toBe(null); + }); + + it('returns null if configurationProvider.openIDConfiguration has no clientId', () => { + const config = { clientId: '' } as OpenIdConfiguration; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'anything', + }); + + const result = service.getAuthorizeParUrl('', config); + + expect(result).toBe(null); + }); + + it('returns correct URL when everything is given', () => { + const config = { clientId: 'clientId' }; + + spyOn(storagePersistenceService, 'read') + .withArgs('authWellKnownEndPoints', config) + .and.returnValue({ + authorizationEndpoint: 'anything', + }); + + const result = service.getAuthorizeParUrl('passedRequestUri', config); + + expect(result).toBe( + 'anything?request_uri=passedRequestUri&client_id=clientId' + ); + }); + }); +}); diff --git a/src/utils/url/url.service.ts b/src/utils/url/url.service.ts new file mode 100644 index 0000000..a1a71bd --- /dev/null +++ b/src/utils/url/url.service.ts @@ -0,0 +1,893 @@ +import { HttpParams } from '@ngify/http'; +import { inject, Injectable } from 'injection-js'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AuthOptions } from '../../auth-options'; +import { OpenIdConfiguration } from '../../config/openid-configuration'; +import { FlowsDataService } from '../../flows/flows-data.service'; +import { LoggerService } from '../../logging/logger.service'; +import { StoragePersistenceService } from '../../storage/storage-persistence.service'; +import { JwtWindowCryptoService } from '../../validation/jwt-window-crypto.service'; +import { FlowHelper } from '../flowHelper/flow-helper.service'; +import { UriEncoder } from './uri-encoder'; + +const CALLBACK_PARAMS_TO_CHECK = ['code', 'state', 'token', 'id_token']; +const AUTH0_ENDPOINT = 'auth0.com'; + +@Injectable() +export class UrlService { + private readonly loggerService = inject(LoggerService); + + private readonly flowsDataService = inject(FlowsDataService); + + private readonly flowHelper = inject(FlowHelper); + + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly jwtWindowCryptoService = inject(JwtWindowCryptoService); + + getUrlParameter(urlToCheck: string, name: string): string { + if (!urlToCheck) { + return ''; + } + + if (!name) { + return ''; + } + + name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]'); + const regex = new RegExp('[\\?&#]' + name + '=([^&#]*)'); + const results = regex.exec(urlToCheck); + + return results === null ? '' : decodeURIComponent(results[1]); + } + + getUrlWithoutQueryParameters(url: URL): URL { + const u = new URL(url.toString()); + + const keys = []; + + for (const key of u.searchParams.keys()) { + keys.push(key); + } + + keys.forEach((key) => { + u.searchParams.delete(key); + }); + + return u; + } + + queryParametersExist(expected: URLSearchParams, actual: URLSearchParams): boolean { + let r = true; + + expected.forEach((v, k) => { + if (!actual.has(k)) { + r = false; + } + }); + + return r; + } + + isCallbackFromSts(currentUrl: string, config?: OpenIdConfiguration): boolean { + if (config && config.checkRedirectUrlWhenCheckingIfIsCallback) { + const currentUrlInstance = new URL(currentUrl); + + const redirectUrl = this.getRedirectUrl(config); + + if (!redirectUrl) { + this.loggerService.logError( + config, + `UrlService.isCallbackFromSts: could not get redirectUrl from config, was: `, + redirectUrl + ); + + return false; + } + + const redirectUriUrlInstance = new URL(redirectUrl); + + const redirectUriWithoutQueryParams = this.getUrlWithoutQueryParameters( + redirectUriUrlInstance + ).toString(); + const currentUrlWithoutQueryParams = + this.getUrlWithoutQueryParameters(currentUrlInstance).toString(); + const redirectUriQueryParamsArePresentInCurrentUrl = + this.queryParametersExist( + redirectUriUrlInstance.searchParams, + currentUrlInstance.searchParams + ); + + if ( + redirectUriWithoutQueryParams !== currentUrlWithoutQueryParams || + !redirectUriQueryParamsArePresentInCurrentUrl + ) { + this.loggerService.logDebug( + config, + 'UrlService.isCallbackFromSts: configured redirectUrl does not match with the current url' + ); + + return false; + } + } + + return CALLBACK_PARAMS_TO_CHECK.some( + (x) => !!this.getUrlParameter(currentUrl, x) + ); + } + + getRefreshSessionSilentRenewUrl( + config: OpenIdConfiguration, + customParams?: { [key: string]: string | number | boolean } + ): Observable { + if (this.flowHelper.isCurrentFlowCodeFlow(config)) { + return this.createUrlCodeFlowWithSilentRenew(config, customParams); + } + + return of(this.createUrlImplicitFlowWithSilentRenew(config, customParams)); + } + + getAuthorizeParUrl( + requestUri: string, + configuration: OpenIdConfiguration + ): string | null { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (!authWellKnownEndPoints) { + this.loggerService.logError( + configuration, + 'authWellKnownEndpoints is undefined' + ); + + return null; + } + + const authorizationEndpoint = authWellKnownEndPoints.authorizationEndpoint; + + if (!authorizationEndpoint) { + this.loggerService.logError( + configuration, + `Can not create an authorize URL when authorizationEndpoint is '${authorizationEndpoint}'` + ); + + return null; + } + + const { clientId } = configuration; + + if (!clientId) { + this.loggerService.logError( + configuration, + `getAuthorizeParUrl could not add clientId because it was: `, + clientId + ); + + return null; + } + + const urlParts = authorizationEndpoint.split('?'); + const authorizationUrl = urlParts[0]; + const existingParams = urlParts[1]; + let params = this.createHttpParams(existingParams); + + params = params.set('request_uri', requestUri); + params = params.append('client_id', clientId); + + return `${authorizationUrl}?${params}`; + } + + getAuthorizeUrl( + config: OpenIdConfiguration | null, + authOptions?: AuthOptions + ): Observable { + if (!config) { + return of(null); + } + + if (this.flowHelper.isCurrentFlowCodeFlow(config)) { + return this.createUrlCodeFlowAuthorize(config, authOptions); + } + + return of(this.createUrlImplicitFlowAuthorize(config, authOptions) || ''); + } + + getEndSessionEndpoint(configuration: OpenIdConfiguration): { + url: string; + existingParams: string; + } { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + const endSessionEndpoint = authWellKnownEndPoints?.endSessionEndpoint; + + if (!endSessionEndpoint) { + return { + url: '', + existingParams: '', + }; + } + + const urlParts = endSessionEndpoint.split('?'); + const url = urlParts[0]; + const existingParams = urlParts[1] ?? ''; + + return { + url, + existingParams, + }; + } + + getEndSessionUrl( + configuration: OpenIdConfiguration | null, + customParams?: { [p: string]: string | number | boolean } + ): string | null { + if (!configuration) { + return null; + } + + const idToken = this.storagePersistenceService.getIdToken(configuration); + const { customParamsEndSessionRequest } = configuration; + const mergedParams = { ...customParamsEndSessionRequest, ...customParams }; + + return this.createEndSessionUrl(idToken, configuration, mergedParams); + } + + createRevocationEndpointBodyAccessToken( + token: string, + configuration: OpenIdConfiguration + ): string | null { + const clientId = this.getClientId(configuration); + + if (!clientId) { + return null; + } + + let params = this.createHttpParams(); + + params = params.set('client_id', clientId); + params = params.set('token', token); + params = params.set('token_type_hint', 'access_token'); + + return params.toString(); + } + + createRevocationEndpointBodyRefreshToken( + token: string, + configuration: OpenIdConfiguration + ): string | null { + const clientId = this.getClientId(configuration); + + if (!clientId) { + return null; + } + + let params = this.createHttpParams(); + + params = params.set('client_id', clientId); + params = params.set('token', token); + params = params.set('token_type_hint', 'refresh_token'); + + return params.toString(); + } + + getRevocationEndpointUrl(configuration: OpenIdConfiguration): string | null { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + const revocationEndpoint = authWellKnownEndPoints?.revocationEndpoint; + + if (!revocationEndpoint) { + return null; + } + + const urlParts = revocationEndpoint.split('?'); + + return urlParts[0]; + } + + createBodyForCodeFlowCodeRequest( + code: string, + configuration: OpenIdConfiguration, + customTokenParams?: { [p: string]: string | number | boolean } + ): string | null { + const clientId = this.getClientId(configuration); + + if (!clientId) { + return null; + } + + let params = this.createHttpParams(); + + params = params.set('grant_type', 'authorization_code'); + params = params.set('client_id', clientId); + + if (!configuration.disablePkce) { + const codeVerifier = this.flowsDataService.getCodeVerifier(configuration); + + if (!codeVerifier) { + this.loggerService.logError( + configuration, + `CodeVerifier is not set `, + codeVerifier + ); + + return null; + } + + params = params.set('code_verifier', codeVerifier); + } + + params = params.set('code', code); + + if (customTokenParams) { + params = this.appendCustomParams({ ...customTokenParams }, params); + } + + const silentRenewUrl = this.getSilentRenewUrl(configuration); + + if ( + this.flowsDataService.isSilentRenewRunning(configuration) && + silentRenewUrl + ) { + params = params.set('redirect_uri', silentRenewUrl); + + return params.toString(); + } + + const redirectUrl = this.getRedirectUrl(configuration); + + if (!redirectUrl) { + return null; + } + + params = params.set('redirect_uri', redirectUrl); + + return params.toString(); + } + + createBodyForCodeFlowRefreshTokensRequest( + refreshToken: string, + configuration: OpenIdConfiguration, + customParamsRefresh?: { [key: string]: string | number | boolean } + ): string | null { + const clientId = this.getClientId(configuration); + + if (!clientId) { + return null; + } + + let params = this.createHttpParams(); + + params = params.set('grant_type', 'refresh_token'); + params = params.set('client_id', clientId); + params = params.set('refresh_token', refreshToken); + + if (customParamsRefresh) { + params = this.appendCustomParams({ ...customParamsRefresh }, params); + } + + return params.toString(); + } + + createBodyForParCodeFlowRequest( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): Observable { + const redirectUrl = this.getRedirectUrl(configuration, authOptions); + + if (!redirectUrl) { + return of(null); + } + + const state = + this.flowsDataService.getExistingOrCreateAuthStateControl(configuration); + const nonce = this.flowsDataService.createNonce(configuration); + + this.loggerService.logDebug( + configuration, + 'Authorize created. adding myautostate: ' + state + ); + + // code_challenge with "S256" + const codeVerifier = + this.flowsDataService.createCodeVerifier(configuration); + + return this.jwtWindowCryptoService.generateCodeChallenge(codeVerifier).pipe( + map((codeChallenge: string) => { + const { + clientId, + responseType, + scope, + hdParam, + customParamsAuthRequest, + } = configuration; + let params = this.createHttpParams(''); + + params = params.set('client_id', clientId ?? ''); + params = params.append('redirect_uri', redirectUrl); + params = params.append('response_type', responseType ?? ''); + params = params.append('scope', scope ?? ''); + params = params.append('nonce', nonce); + params = params.append('state', state); + params = params.append('code_challenge', codeChallenge); + params = params.append('code_challenge_method', 'S256'); + + if (hdParam) { + params = params.append('hd', hdParam); + } + + if (customParamsAuthRequest) { + params = this.appendCustomParams( + { ...customParamsAuthRequest }, + params + ); + } + + if (authOptions?.customParams) { + params = this.appendCustomParams( + { ...authOptions.customParams }, + params + ); + } + + return params.toString(); + }) + ); + } + + getPostLogoutRedirectUrl(configuration: OpenIdConfiguration): string | null { + const { postLogoutRedirectUri } = configuration; + + if (!postLogoutRedirectUri) { + this.loggerService.logError( + configuration, + `could not get postLogoutRedirectUri, was: `, + postLogoutRedirectUri + ); + + return null; + } + + return postLogoutRedirectUri; + } + + private createEndSessionUrl( + idTokenHint: string, + configuration: OpenIdConfiguration, + customParamsEndSession?: { [p: string]: string | number | boolean } + ): string | null { + // Auth0 needs a special logout url + // See https://auth0.com/docs/api/authentication#logout + + if (this.isAuth0Endpoint(configuration)) { + return this.composeAuth0Endpoint(configuration); + } + + const { url, existingParams } = this.getEndSessionEndpoint(configuration); + + if (!url) { + return null; + } + + let params = this.createHttpParams(existingParams); + + if (!!idTokenHint) { + params = params.set('id_token_hint', idTokenHint); + } + + const postLogoutRedirectUri = this.getPostLogoutRedirectUrl(configuration); + + if (postLogoutRedirectUri) { + params = params.append('post_logout_redirect_uri', postLogoutRedirectUri); + } + + if (customParamsEndSession) { + params = this.appendCustomParams({ ...customParamsEndSession }, params); + } + + return `${url}?${params}`; + } + + private createAuthorizeUrl( + codeChallenge: string, + redirectUrl: string, + nonce: string, + state: string, + configuration: OpenIdConfiguration, + prompt?: string, + customRequestParams?: { [key: string]: string | number | boolean } + ): string { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + const authorizationEndpoint = authWellKnownEndPoints?.authorizationEndpoint; + + if (!authorizationEndpoint) { + this.loggerService.logError( + configuration, + `Can not create an authorize URL when authorizationEndpoint is '${authorizationEndpoint}'` + ); + + return ''; + } + + const { clientId, responseType, scope, hdParam, customParamsAuthRequest } = + configuration; + + if (!clientId) { + this.loggerService.logError( + configuration, + `createAuthorizeUrl could not add clientId because it was: `, + clientId + ); + + return ''; + } + + if (!responseType) { + this.loggerService.logError( + configuration, + `createAuthorizeUrl could not add responseType because it was: `, + responseType + ); + + return ''; + } + + if (!scope) { + this.loggerService.logError( + configuration, + `createAuthorizeUrl could not add scope because it was: `, + scope + ); + + return ''; + } + + const urlParts = authorizationEndpoint.split('?'); + const authorizationUrl = urlParts[0]; + const existingParams = urlParts[1]; + let params = this.createHttpParams(existingParams); + + params = params.set('client_id', clientId); + params = params.append('redirect_uri', redirectUrl); + params = params.append('response_type', responseType); + params = params.append('scope', scope); + params = params.append('nonce', nonce); + params = params.append('state', state); + + if (this.flowHelper.isCurrentFlowCodeFlow(configuration)) { + params = params.append('code_challenge', codeChallenge); + params = params.append('code_challenge_method', 'S256'); + } + + const mergedParams = { ...customParamsAuthRequest, ...customRequestParams }; + + if (Object.keys(mergedParams).length > 0) { + params = this.appendCustomParams({ ...mergedParams }, params); + } + + if (prompt) { + params = this.overWriteParam(params, 'prompt', prompt); + } + + if (hdParam) { + params = params.append('hd', hdParam); + } + + return `${authorizationUrl}?${params}`; + } + + private createUrlImplicitFlowWithSilentRenew( + configuration: OpenIdConfiguration, + customParams?: { [key: string]: string | number | boolean } + ): string | null { + const state = + this.flowsDataService.getExistingOrCreateAuthStateControl(configuration); + const nonce = this.flowsDataService.createNonce(configuration); + const silentRenewUrl = this.getSilentRenewUrl(configuration); + + if (!silentRenewUrl) { + return null; + } + + this.loggerService.logDebug( + configuration, + 'RefreshSession created. adding myautostate: ', + state + ); + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (authWellKnownEndPoints) { + return this.createAuthorizeUrl( + '', + silentRenewUrl, + nonce, + state, + configuration, + 'none', + customParams + ); + } + + this.loggerService.logError( + configuration, + 'authWellKnownEndpoints is undefined' + ); + + return null; + } + + private createUrlCodeFlowWithSilentRenew( + configuration: OpenIdConfiguration, + customParams?: { [key: string]: string | number | boolean } + ): Observable { + const state = + this.flowsDataService.getExistingOrCreateAuthStateControl(configuration); + const nonce = this.flowsDataService.createNonce(configuration); + + this.loggerService.logDebug( + configuration, + 'RefreshSession created. adding myautostate: ' + state + ); + + // code_challenge with "S256" + const codeVerifier = + this.flowsDataService.createCodeVerifier(configuration); + + return this.jwtWindowCryptoService.generateCodeChallenge(codeVerifier).pipe( + map((codeChallenge: string) => { + const silentRenewUrl = this.getSilentRenewUrl(configuration); + + if (!silentRenewUrl) { + return ''; + } + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (authWellKnownEndPoints) { + return this.createAuthorizeUrl( + codeChallenge, + silentRenewUrl, + nonce, + state, + configuration, + 'none', + customParams + ); + } + + this.loggerService.logWarning( + configuration, + 'authWellKnownEndpoints is undefined' + ); + + return ''; + }) + ); + } + + private createUrlImplicitFlowAuthorize( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): string | null { + const state = + this.flowsDataService.getExistingOrCreateAuthStateControl(configuration); + const nonce = this.flowsDataService.createNonce(configuration); + + this.loggerService.logDebug( + configuration, + 'Authorize created. adding myautostate: ' + state + ); + + const redirectUrl = this.getRedirectUrl(configuration, authOptions); + + if (!redirectUrl) { + return null; + } + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (authWellKnownEndPoints) { + const { customParams } = authOptions || {}; + + return this.createAuthorizeUrl( + '', + redirectUrl, + nonce, + state, + configuration, + '', + customParams + ); + } + + this.loggerService.logError( + configuration, + 'authWellKnownEndpoints is undefined' + ); + + return null; + } + + private createUrlCodeFlowAuthorize( + config: OpenIdConfiguration, + authOptions?: AuthOptions + ): Observable { + const state = + this.flowsDataService.getExistingOrCreateAuthStateControl(config); + const nonce = this.flowsDataService.createNonce(config); + + this.loggerService.logDebug( + config, + 'Authorize created. adding myautostate: ' + state + ); + + const redirectUrl = this.getRedirectUrl(config, authOptions); + + if (!redirectUrl) { + return of(null); + } + + return this.getCodeChallenge(config).pipe( + map((codeChallenge: string) => { + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + config + ); + + if (authWellKnownEndPoints) { + const { customParams } = authOptions || {}; + + return this.createAuthorizeUrl( + codeChallenge, + redirectUrl, + nonce, + state, + config, + '', + customParams + ); + } + + this.loggerService.logError( + config, + 'authWellKnownEndpoints is undefined' + ); + + return ''; + }) + ); + } + + private getCodeChallenge(config: OpenIdConfiguration): Observable { + if (config.disablePkce) { + return of(''); + } + + // code_challenge with "S256" + const codeVerifier = this.flowsDataService.createCodeVerifier(config); + + return this.jwtWindowCryptoService.generateCodeChallenge(codeVerifier); + } + + private getRedirectUrl( + configuration: OpenIdConfiguration, + authOptions?: AuthOptions + ): string | null { + let { redirectUrl } = configuration; + + if (authOptions?.redirectUrl) { + // override by redirectUrl from authOptions + redirectUrl = authOptions.redirectUrl; + } + + if (!redirectUrl) { + this.loggerService.logError( + configuration, + `could not get redirectUrl, was: `, + redirectUrl + ); + + return null; + } + + return redirectUrl; + } + + private getSilentRenewUrl(configuration: OpenIdConfiguration): string | null { + const { silentRenewUrl } = configuration; + + if (!silentRenewUrl) { + this.loggerService.logError( + configuration, + `could not get silentRenewUrl, was: `, + silentRenewUrl + ); + + return null; + } + + return silentRenewUrl; + } + + private getClientId(configuration: OpenIdConfiguration): string | null { + const { clientId } = configuration; + + if (!clientId) { + this.loggerService.logError( + configuration, + `could not get clientId, was: `, + clientId + ); + + return null; + } + + return clientId; + } + + private appendCustomParams( + customParams: { [key: string]: string | number | boolean }, + params: HttpParams + ): HttpParams { + for (const [key, value] of Object.entries({ ...customParams })) { + params = params.append(key, value.toString()); + } + + return params; + } + + private overWriteParam( + params: HttpParams, + key: string, + value: string | number | boolean + ): HttpParams { + return params.set(key, value); + } + + private createHttpParams(existingParams?: string): HttpParams { + existingParams = existingParams ?? ''; + + return new HttpParams(existingParams, { + encoder: new UriEncoder(), + }); + } + + private isAuth0Endpoint(configuration: OpenIdConfiguration): boolean { + const { authority, useCustomAuth0Domain } = configuration; + + if (!authority) { + return false; + } + + return authority.endsWith(AUTH0_ENDPOINT) || Boolean(useCustomAuth0Domain); + } + + private composeAuth0Endpoint(configuration: OpenIdConfiguration): string { + // format: https://YOUR_DOMAIN/v2/logout?client_id=YOUR_CLIENT_ID&returnTo=LOGOUT_URL + const { authority, clientId } = configuration; + const postLogoutRedirectUrl = this.getPostLogoutRedirectUrl(configuration); + + return `${authority}/v2/logout?client_id=${clientId}&returnTo=${postLogoutRedirectUrl}`; + } +} diff --git a/src/validation/jwk-window-crypto.service.spec.ts b/src/validation/jwk-window-crypto.service.spec.ts new file mode 100644 index 0000000..f2737b9 --- /dev/null +++ b/src/validation/jwk-window-crypto.service.spec.ts @@ -0,0 +1,92 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { base64url } from 'rfc4648'; +import { CryptoService } from '../utils/crypto/crypto.service'; +import { JwkWindowCryptoService } from './jwk-window-crypto.service'; + +describe('JwkWindowCryptoService', () => { + let service: JwkWindowCryptoService; + const alg = { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }; + const key1 = { + kty: 'RSA', + use: 'sig', + kid: '5626CE6A8F4F5FCD79C6642345282CA76D337548RS256', + x5t: 'VibOao9PX815xmQjRSgsp20zdUg', + e: 'AQAB', + n: 'uu3-HK4pLRHJHoEBzFhM516RWx6nybG5yQjH4NbKjfGQ8dtKy1BcGjqfMaEKF8KOK44NbAx7rtBKCO9EKNYkeFvcUzBzVeuu4jWG61XYdTekgv-Dh_Fj8245GocEkbvBbFW6cw-_N59JWqUuiCvb-EOfhcuubUcr44a0AQyNccYNpcXGRcMKy7_L1YhO0AMULqLDDVLFj5glh4TcJ2N5VnJedq1-_JKOxPqD1ni26UOQoWrW16G29KZ1_4Xxf2jX8TAq-4RJEHccdzgZVIO4F5B4MucMZGq8_jMCpiTUsUGDOAMA_AmjxIRHOtO5n6Pt0wofrKoAVhGh2sCTtaQf2Q', + x5c: [ + 'MIIDPzCCAiegAwIBAgIQF+HRVxLHII9IlOoQk6BxcjANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBBzdHMuZGV2LmNlcnQuY29tMB4XDTE5MDIyMDEwMTA0M1oXDTM5MDIyMDEwMTkyOVowGzEZMBcGA1UEAwwQc3RzLmRldi5jZXJ0LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALrt/hyuKS0RyR6BAcxYTOdekVsep8mxuckIx+DWyo3xkPHbSstQXBo6nzGhChfCjiuODWwMe67QSgjvRCjWJHhb3FMwc1XrruI1hutV2HU3pIL/g4fxY/NuORqHBJG7wWxVunMPvzefSVqlLogr2/hDn4XLrm1HK+OGtAEMjXHGDaXFxkXDCsu/y9WITtADFC6iww1SxY+YJYeE3CdjeVZyXnatfvySjsT6g9Z4tulDkKFq1tehtvSmdf+F8X9o1/EwKvuESRB3HHc4GVSDuBeQeDLnDGRqvP4zAqYk1LFBgzgDAPwJo8SERzrTuZ+j7dMKH6yqAFYRodrAk7WkH9kCAwEAAaN/MH0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAtBgNVHREEJjAkghBzdHMuZGV2LmNlcnQuY29tghBzdHMuZGV2LmNlcnQuY29tMB0GA1UdDgQWBBQuyHxWP3je6jGMOmOiY+hz47r36jANBgkqhkiG9w0BAQsFAAOCAQEAKEHG7Ga6nb2XiHXDc69KsIJwbO80+LE8HVJojvITILz3juN6/FmK0HmogjU6cYST7m1MyxsVhQQNwJASZ6haBNuBbNzBXfyyfb4kr62t1oDLNwhctHaHaM4sJSf/xIw+YO+Qf7BtfRAVsbM05+QXIi2LycGrzELiXu7KFM0E1+T8UOZ2Qyv7OlCb/pWkYuDgE4w97ox0MhDpvgluxZLpRanOLUCVGrfFaij7gRAhjYPUY3vAEcD8JcFBz1XijU8ozRO6FaG4qg8/JCe+VgoWsMDj3sKB9g0ob6KCyG9L2bdk99PGgvXDQvMYCpkpZzG3XsxOINPd5p0gc209ZOoxTg==', + ], + alg: 'RS256', + } as JsonWebKey; + const key2 = { + kty: 'RSA', + n: 'wq0vJv4Xl2xSQTN75_N4JeFHlHH80PytypJqyNrhWIp1P9Ur4-5QSiS8BI8PYSh0dQy4NMoj9YMRcyge3y81uCCwxouePiAGc0xPy6QkAOiinvV3KJEMtbppicOvZEzMXb3EqRM-9Twxbp2hhBAPSAhyL79Rwy4JuIQ6imaqL0NIEGv8_BOe_twMPOLGTJhepDO6kDs6O0qlLgPRHQVuKAz3afVby0C2myDLpo5YaI66arU9VXXGQtIp8MhBY9KbsGaYskejSWhSBOcwdtYMEo5rXWGGVnrHiSqq8mm-sVXLQBe5xPFBs4IQ_Gz4nspr05LEEbsHSwFyGq5D77XPxGUPDCq5ZVvON0yBizaHcJ-KA0Lw6uXtOH9-YyVGuaBynkrQEo3pP2iy1uWt-TiQPb8PMsCAdWZP-6R0QKHtjds9HmjIkgFTJSTIeETjNck_bB4ud79gZT-INikjPFTTeyQYk2jqxEJanVe3k0i_1vpskRpknJ7F2vTL45LAQkjWvczjWmHxGA5D4-1msuylXpY8Y4WxnUq6dRTEN29IRVCil9Mfp6JMsquFGTvJO0-Ffl0_suMZZl3uXNt23E9vGreByalWHivYmfpIor5Q5JaFKekRVV-U1KDBaeQQaHp_VqliUKImdUE9-GXNOIaBMjRvfy0nxsRe_q_dD6jc_GU', + e: 'AQAB', + } as JsonWebKey; + const key3 = { + kty: 'RSA', + n: 'u1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0_IzW7yWR7QkrmBL7jTKEn5u-qKhbwKfBstIs-bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW_VDL5AaWTg0nLVkjRo9z-40RQzuVaE8AkAFmxZzow3x-VJYKdjykkJ0iT9wCS0DRTXu269V264Vf_3jvredZiKRkgwlL9xNAwxXFg0x_XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC-9aGVd-Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmw', + e: 'AQAB', + alg: 'RS256', + kid: 'boop', + use: 'sig', + } as JsonWebKey; + const keys: JsonWebKey[] = [key1, key2, key3]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [JwkWindowCryptoService, CryptoService], + }); + }); + + beforeEach(waitForAsync(() => { + service = TestBed.inject(JwkWindowCryptoService); + })); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('importVerificationKey', () => { + it('returns instance of CryptoKey when valid input is provided', (done) => { + const promises = keys.map((key) => + service.importVerificationKey(key, alg) + ); + + Promise.all(promises).then((values) => { + values.forEach((value) => { + expect(value).toBeInstanceOf(CryptoKey); + }); + done(); + }); + }); + }); + + describe('verifyKey', () => { + it('returns true when valid input is provided', (done) => { + const headerAndPayloadString = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0'; + const signatureString = + 'NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ'; + const signature: Uint8Array = base64url.parse(signatureString, { + loose: true, + }); + + service + .importVerificationKey(key3, alg) + .then((c) => + service.verifyKey(alg, c, signature, headerAndPayloadString) + ) + .then((value) => { + expect(value).toEqual(true); + }) + .finally(() => { + done(); + }); + }); + }); +}); diff --git a/src/validation/jwk-window-crypto.service.ts b/src/validation/jwk-window-crypto.service.ts new file mode 100644 index 0000000..ffb76d6 --- /dev/null +++ b/src/validation/jwk-window-crypto.service.ts @@ -0,0 +1,38 @@ +import { inject, Injectable } from 'injection-js'; +import { CryptoService } from '../utils/crypto/crypto.service'; + +@Injectable() +export class JwkWindowCryptoService { + private readonly cryptoService = inject(CryptoService); + + importVerificationKey( + key: JsonWebKey, + algorithm: + | AlgorithmIdentifier + | RsaHashedImportParams + | EcKeyImportParams + | HmacImportParams + | AesKeyAlgorithm + | null + ): Promise { + return this.cryptoService + .getCrypto() + .subtle.importKey('jwk', key, algorithm, false, ['verify']); + } + + verifyKey( + verifyAlgorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams | null, + cryptoKey: CryptoKey, + signature: BufferSource, + signingInput: string + ): Promise { + return this.cryptoService + .getCrypto() + .subtle.verify( + verifyAlgorithm, + cryptoKey, + signature, + new TextEncoder().encode(signingInput) + ); + } +} diff --git a/src/validation/jwt-window-crypto.service.spec.ts b/src/validation/jwt-window-crypto.service.spec.ts new file mode 100644 index 0000000..ecc3f53 --- /dev/null +++ b/src/validation/jwt-window-crypto.service.spec.ts @@ -0,0 +1,35 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { CryptoService } from '../utils/crypto/crypto.service'; +import { JwtWindowCryptoService } from './jwt-window-crypto.service'; + +describe('JwtWindowCryptoService', () => { + let service: JwtWindowCryptoService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [JwtWindowCryptoService, CryptoService], + }); + }); + + beforeEach(() => { + service = TestBed.inject(JwtWindowCryptoService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('generateCodeChallenge', () => { + it('returns good result with correct codeVerifier', waitForAsync(() => { + const outcome = 'R2TWD45Vtcf_kfAqjuE3LMSRF3JDE5fsFndnn6-a0nQ'; + const observable = service.generateCodeChallenge( + '44445543344242132145455aaabbdc3b4' + ); + + observable.subscribe((value) => { + expect(value).toBe(outcome); + }); + })); + }); +}); diff --git a/src/validation/jwt-window-crypto.service.ts b/src/validation/jwt-window-crypto.service.ts new file mode 100644 index 0000000..34a6abf --- /dev/null +++ b/src/validation/jwt-window-crypto.service.ts @@ -0,0 +1,63 @@ +import { inject, Injectable } from 'injection-js'; +import { from, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { CryptoService } from '../utils/crypto/crypto.service'; + +@Injectable() +export class JwtWindowCryptoService { + private readonly cryptoService = inject(CryptoService); + + generateCodeChallenge(codeVerifier: string): Observable { + return this.calcHash(codeVerifier).pipe( + map((challengeRaw: string) => this.base64UrlEncode(challengeRaw)) + ); + } + + generateAtHash(accessToken: string, algorithm: string): Observable { + return this.calcHash(accessToken, algorithm).pipe( + map((tokenHash) => { + const substr: string = tokenHash.substr(0, tokenHash.length / 2); + const tokenHashBase64: string = btoa(substr); + + return tokenHashBase64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + }) + ); + } + + private calcHash( + valueToHash: string, + algorithm = 'SHA-256' + ): Observable { + const msgBuffer: Uint8Array = new TextEncoder().encode(valueToHash); + + return from( + this.cryptoService.getCrypto().subtle.digest(algorithm, msgBuffer) + ).pipe( + map((hashBuffer: unknown) => { + const buffer = hashBuffer as ArrayBuffer; + const hashArray: number[] = Array.from(new Uint8Array(buffer)); + + return this.toHashString(hashArray); + }) + ); + } + + private toHashString(byteArray: number[]): string { + let result = ''; + + for (const e of byteArray) { + result += String.fromCharCode(e); + } + + return result; + } + + private base64UrlEncode(str: string): string { + const base64: string = btoa(str); + + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } +} diff --git a/src/validation/jwtkeys.ts b/src/validation/jwtkeys.ts new file mode 100644 index 0000000..08fde69 --- /dev/null +++ b/src/validation/jwtkeys.ts @@ -0,0 +1,13 @@ +export interface JwtKeys { + keys: JwtKey[]; +} + +export interface JwtKey { + kty: string; + use: string; + kid: string; + x5t: string; + e: string; + n: string; + x5c: any[]; +} diff --git a/src/validation/state-validation-result.ts b/src/validation/state-validation-result.ts new file mode 100644 index 0000000..224cb39 --- /dev/null +++ b/src/validation/state-validation-result.ts @@ -0,0 +1,13 @@ +import { ValidationResult } from './validation-result'; + +export class StateValidationResult { + constructor( + public accessToken = '', + public idToken = '', + public authResponseIsValid = false, + public decodedIdToken: any = { + at_hash: '', + }, + public state: ValidationResult = ValidationResult.NotSet + ) {} +} diff --git a/src/validation/state-validation.service.spec.ts b/src/validation/state-validation.service.spec.ts new file mode 100644 index 0000000..3314271 --- /dev/null +++ b/src/validation/state-validation.service.spec.ts @@ -0,0 +1,2155 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { AuthWellKnownEndpoints } from '../config/auth-well-known/auth-well-known-endpoints'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { LogLevel } from '../logging/log-level'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { EqualityService } from '../utils/equality/equality.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { StateValidationService } from './state-validation.service'; +import { TokenValidationService } from './token-validation.service'; +import { ValidationResult } from './validation-result'; + +describe('State Validation Service', () => { + let stateValidationService: StateValidationService; + let tokenValidationService: TokenValidationService; + let tokenHelperService: TokenHelperService; + let loggerService: LoggerService; + let config: OpenIdConfiguration; + let authWellKnownEndpoints: AuthWellKnownEndpoints; + let storagePersistenceService: StoragePersistenceService; + let flowHelper: FlowHelper; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + mockProvider(StoragePersistenceService), + mockProvider(TokenValidationService), + mockProvider(LoggerService), + TokenHelperService, + EqualityService, + FlowHelper, + ], + }); + }); + + beforeEach(() => { + stateValidationService = TestBed.inject(StateValidationService); + tokenValidationService = TestBed.inject(TokenValidationService); + tokenHelperService = TestBed.inject(TokenHelperService); + loggerService = TestBed.inject(LoggerService); + storagePersistenceService = TestBed.inject(StoragePersistenceService); + flowHelper = TestBed.inject(FlowHelper); + }); + + beforeEach(() => { + config = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'id_token token', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + }; + + authWellKnownEndpoints = { + issuer: 'https://localhost:44363', + jwksUri: 'https://localhost:44363/well-known/openid-configuration/jwks', + authorizationEndpoint: 'https://localhost:44363/connect/authorize', + tokenEndpoint: 'https://localhost:44363/connect/token', + userInfoEndpoint: 'https://localhost:44363/connect/userinfo', + endSessionEndpoint: 'https://localhost:44363/connect/endsession', + checkSessionIframe: 'https://localhost:44363/connect/checksession', + revocationEndpoint: 'https://localhost:44363/connect/revocation', + introspectionEndpoint: 'https://localhost:44363/connect/introspect', + }; + }); + + it('should create', () => { + expect(stateValidationService).toBeTruthy(); + expect(tokenValidationService).toBeTruthy(); + }); + + describe('isIdTokenAfterRefreshTokenRequestValid', () => { + it('validate refresh good ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: true, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206486, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(true); + }); + + it('validate refresh invalid iss ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: true, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.ch/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206486, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(false); + }); + + it('validate refresh invalid sub ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: true, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f7', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206486, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(false); + }); + + it('validate refresh invalid auth_time ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206488, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(false); + }); + + it('validate refresh good full', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206486, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(true); + }); + + it('validate refresh good no existing id_token', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: null, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206486, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(true); + }); + + it('validate refresh invalid aud ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206488, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(false); + }); + + it('validate refresh invalid azp ', () => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJpc3MiOiJodHRwczovL2RhbWllbmJvZC5iMmNsb2dpbi5jb20vYTA5NThmNDUtMTk1Yi00MDM2LTkyNTktZGUyZjdlNTk0ZGI2L3YyLjAvIiwiZXhwIjoxNTg5MjEwMDg2LCJuYmYiOjE1ODkyMDY0ODYsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsIm5hbWUiOiJkYW1pZW5ib2QiLCJlbWFpbHMiOlsiZGFtaWVuQGRhbWllbmJvZC5vbm1pY3Jvc29mdC5jb20iXSwidGZwIjoiQjJDXzFfYjJjcG9saWN5ZGFtaWVuIiwibm9uY2UiOiIwMDdjNDE1M2I2YTA1MTdjMGU0OTc0NzZmYjI0OTk0OGVjNWNsT3ZRUSIsInNjcCI6ImRlbW8ucmVhZCIsImF6cCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsInZlciI6IjEuMCIsImlhdCI6MTU4OTIwNjQ4Nn0.Zyg8GAsyj8_ljdheJ57oQ8ldZMon4nLs1VCkBnIon2cXGrXlTA_fYP_Ypf5x5OZcCg-wXdo9RttsLRD69v1cnd5eUc9crzkJ18BruRdhoVQdlrGuakwKujozY2-EU8KNH64qSDpPOqQ9m4jdzGAOkY0wWitOlvYoNZHDzDS4ZIWn8W5H2nwAbf8LMAdXqy41YaIBF4lo3ZaKoUKQqCwIG_0aLvRQcmiwkEoQ5-EUb_hdOejTIbIT5PryyqMnvJYgyrKTf1VY060YpETH19PMosNriwPrPesJhsruphqzaJexg0Pt09ILoMHJhebkON-oPjXLjDOGLfnRTPp6oP_Drg'; + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const refreshTokenData = + 'eyJraWQiOiJjcGltY29yZV8wOTI1MjAxNSIsInZlciI6IjEuMCIsInppcCI6IkRlZmxhdGUiLCJzZXIiOiIxLjAifQ..Gn8_Hs0IAsJm7Tlw.4dvuowpuUHz2RifIINXM5mBbiOorKgAWZapLdohY9LYd4yxAr-K2E8PFCi_lmbTfY0nxXkRqL9S_JnJKP_2Sd_R0g3PC5weu9XxGIT-oWATtkVX4KDWlAsN0-xWUosulT4LEbFygC3bA6B5Ch2BgN_zZ5L-aJjwE1JkE55tQCDgT2tS6uRQjvh1U3ddWgYEsmCqbWQnwbMPPkxA-PvXXTtUKqXTzAo0T9tLBXrSaXurq0Y-visy036Sy9Y7f-duiTLMJ8WKw_XYz3uzsj7Y0SV2A3m2rJNs3HjPBRUOyyWpdhmjo3VAes1bc8nZuZHsP4S2HSe7hRoOxYkWfGhIBvI8FT3dBZKfttAT64fsR-fQtQ4ia0z12SsLoCJhF1VRf3NU1-Lc2raP0kvN7HOGQFuVPkjmWOqKKoy4at7PAvC_sWHOND7QkmYkFyfQvGcNmt_lA10VZlr_cOeuiNCTPUHZHi-pv7nsefxVoPYGJPztGvIJ_daAUigXMZGARTTIhCt84PzPEdPMlCSI3GuNxQoD95rhvSyZP8SBQ5NIs_qwxYMAfzXgJP8aFK-ZHd8ZQfm1Rg79mO0LH1GcQzIhc4pC4PsvcSm6I6Jo1ZeEw5pRQQWf59asPyORG-2qfnMvZB1hGCZU7J78lAcse6sXCtBlQDLe9Th5Goibn.XdCGzjyrmgKzJktSPSDH0g'; + + const configRefresh = { + authority: 'https://localhost:44363', + redirectUrl: 'https://localhost:44363', + clientId: 'singleapp', + responseType: 'icode', + scope: 'dataEventRecords openid', + postLogoutRedirectUri: 'https://localhost:44363/Unauthorized', + startCheckSession: false, + silentRenew: true, + silentRenewUrl: 'https://localhost:44363/silent-renew.html', + postLoginRoute: '/dataeventrecords', + forbiddenRoute: '/Forbidden', + unauthorizedRoute: '/Unauthorized', + logLevel: LogLevel.Debug, + maxIdTokenIatOffsetAllowedInSeconds: 10, + useRefreshToken: true, + ignoreNonceAfterRefresh: true, + disableRefreshIdTokenAuthTimeValidation: false, + triggerRefreshWhenIdTokenExpired: true, + }; + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: refreshTokenData, + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: idToken, + authResult: { + access_token: accessToken, + id_token: idToken, + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const decodedIdToken = { + exp: 1589210086, + nbf: 1589206486, + ver: '1.0', + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'f1934a6e-958d-4198-9f36-6127cfc4cdb3', + nonce: '007c4153b6a0517c0e497476fb249948ec5clOvQQ', + iat: 1589206486, + auth_time: 1589206488, + name: 'damienbod', + emails: ['damien@damienbod.onmicrosoft.com'], + tfp: 'B2C_1_b2cpolicydamien', + at_hash: 'Zk0fKJS_pYhOpM8IBa12fw', + azp: 'no bad', + }; + const isValid = ( + stateValidationService as any + ).isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + decodedIdToken, + configRefresh + ); + + expect(isValid).toBe(false); + }); + }); + + describe('getValidatedStateResult', () => { + it('should return authResponseIsValid false when null is passed', waitForAsync(() => { + const isValidObs$ = stateValidationService.getValidatedStateResult( + {} as CallbackContext, + config + ); + + isValidObs$.subscribe((isValid) => { + expect(isValid.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid context error', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'id_token token'; + + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue( + false + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: null, + authResult: { + error: 'access_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const isValidObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + isValidObs$.subscribe((isValid) => { + expect(isValid.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenExpNotExpired is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'id_token token'; + + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + + spyOn(tokenValidationService, 'hasIdTokenExpired').and.returnValue(false); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + + config.clientId = ''; + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(false); + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback id token expired' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateStateFromHashCallback is false', waitForAsync(() => { + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(false); + + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + expect( + tokenValidationService.validateStateFromHashCallback + ).toHaveBeenCalled(); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect state' + ); + expect(state.accessToken).toBe(''); + expect(state.authResponseIsValid).toBe(false); + expect(state.decodedIdToken).toBeDefined(); + expect(state.idToken).toBe(''); + }); + })); + + it('access_token should equal result.access_token and is valid if response_type is "id_token token"', waitForAsync(() => { + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'hasIdTokenExpired').and.returnValue(false); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(true) + ); + + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + config.clientId = ''; + config.autoCleanStateAfterAuthentication = false; + config.responseType = 'id_token token'; + + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(true); + }); + })); + + it('should return invalid result if validateSignatureIdToken is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(false) + ); + + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + const logDebugSpy = spyOn(loggerService, 'logDebug').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logDebugSpy.calls.allArgs()).toEqual([ + [config, 'authCallback Signature validation failed id_token'], + [config, 'authCallback token(s) invalid'], + ]); + + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenNonce is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + false + ); + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect nonce, did you call the checkAuth() method multiple times?' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateRequiredIdToken is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'id_token token'; + + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + false + ); + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logDebugSpy = spyOn(loggerService, 'logDebug').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logDebugSpy).toHaveBeenCalledWith( + config, + 'authCallback Validation, one of the REQUIRED properties missing from id_token' + ); + expect(logDebugSpy).toHaveBeenCalledWith( + config, + 'authCallback token(s) invalid' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenIatMaxOffset is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'id_token token'; + + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(false); + + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback Validation, iat rejected id_token was issued too far away from the current time' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenIss is false and has authWellKnownEndPoints', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'id_token token'; + + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue( + false + ); + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect iss does not match authWellKnownEndpoints issuer' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenIss is false and has no authWellKnownEndPoints', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy.withArgs('authWellKnownEndPoints', config).and.returnValue(null); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authWellKnownEndpoints is undefined' + ); + + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + expect(state.state).toBe(ValidationResult.NoAuthWellKnownEndPoints); + }); + })); + + it('should return invalid result if validateIdTokenAud is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue( + false + ); + + config.clientId = ''; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect aud' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return invalid result if validateIdTokenAzpExistsIfMoreThanOneAud is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(false); + + config.clientId = ''; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback missing azp' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + expect(state.state).toBe(ValidationResult.IncorrectAzp); + }); + })); + + it('should return invalid result if validateIdTokenAzpValid is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + false + ); + + config.clientId = ''; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect azp' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + expect(state.state).toBe(ValidationResult.IncorrectAzp); + }); + })); + + it('should return invalid result if isIdTokenAfterRefreshTokenRequestValid is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + config.responseType = 'id_token token'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + spyOn( + stateValidationService as any, + 'isIdTokenAfterRefreshTokenRequestValid' + ).and.returnValue(false); + + config.clientId = ''; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback pre, post id_token claims do not match in refresh' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + expect(state.state).toBe( + ValidationResult.IncorrectIdTokenClaimsAfterRefresh + ); + }); + })); + + it('Reponse is valid if authConfiguration.response_type does not equal "id_token token"', waitForAsync(() => { + spyOn(tokenValidationService, 'hasIdTokenExpired').and.returnValue(false); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(true) + ); + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + config.clientId = ''; + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(true); + config.responseType = 'NOT id_token token'; + config.autoCleanStateAfterAuthentication = false; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const logDebugSpy = spyOn(loggerService, 'logDebug').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logDebugSpy).toHaveBeenCalledWith( + config, + 'authCallback token(s) validated, continue' + ); + expect(logDebugSpy).toHaveBeenCalledWith( + config, + 'authCallback token(s) invalid' + ); + expect(state.accessToken).toBe(''); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(true); + }); + })); + + it('Response is invalid if validateIdTokenAtHash is false', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + config.clientId = ''; + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(true); + config.responseType = 'id_token token'; + config.autoCleanStateAfterAuthentication = false; + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(false) + ); + + spyOn(tokenValidationService, 'hasIdTokenExpired').and.returnValue(false); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const logWarningSpy = spyOn(loggerService, 'logWarning').and.callFake( + () => undefined + ); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logWarningSpy).toHaveBeenCalledOnceWith( + config, + 'authCallback incorrect at_hash' + ); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe('id_tokenTEST'); + expect(state.decodedIdToken).toBe('decoded_id_token'); + expect(state.authResponseIsValid).toBe(false); + }); + })); + + it('should return valid result if validateIdTokenIss is false and iss_validation_off is true', waitForAsync(() => { + config.issValidationOff = true; + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue( + false + ); + + spyOn(tokenValidationService, 'hasIdTokenExpired').and.returnValue(false); + spyOn( + tokenValidationService, + 'validateAccessTokenNotExpired' + ).and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenAzpExistsIfMoreThanOneAud' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAzpValid').and.returnValue( + true + ); + + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(true) + ); + config.responseType = 'id_token token'; + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const logDebugSpy = spyOn(loggerService, 'logDebug'); // .and.callFake(() => undefined); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: 'id_tokenTEST', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(logDebugSpy.calls.allArgs()).toEqual([ + [config, 'iss validation is turned off, this is not recommended!'], + [config, 'authCallback token(s) validated, continue'], + ]); + expect(state.state).toBe(ValidationResult.Ok); + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.authResponseIsValid).toBe(true); + expect(state.decodedIdToken).toBeDefined(); + expect(state.idToken).toBe('id_tokenTEST'); + }); + })); + + it('should return valid if there is no id_token', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + + config.responseType = 'code'; + spyOn(tokenHelperService, 'getPayloadFromToken').and.returnValue( + 'decoded_id_token' + ); + spyOn(tokenValidationService, 'validateSignatureIdToken').and.returnValue( + of(true) + ); + spyOn(tokenValidationService, 'validateIdTokenNonce').and.returnValue( + true + ); + spyOn(tokenValidationService, 'validateRequiredIdToken').and.returnValue( + true + ); + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + config.clientId = ''; + spyOn( + tokenValidationService, + 'validateIdTokenIatMaxOffset' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAud').and.returnValue(true); + spyOn( + tokenValidationService, + 'validateIdTokenExpNotExpired' + ).and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenIss').and.returnValue(true); + spyOn(tokenValidationService, 'validateIdTokenAtHash').and.returnValue( + of(true) + ); + + config.autoCleanStateAfterAuthentication = false; + + const readSpy = spyOn(storagePersistenceService, 'read'); + + readSpy + .withArgs('authWellKnownEndPoints', config) + .and.returnValue(authWellKnownEndpoints); + readSpy + .withArgs('authStateControl', config) + .and.returnValue('authStateControl'); + readSpy.withArgs('authNonce', config).and.returnValue('authNonce'); + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsdfhhhhsdf', + sessionState: 'fdffsggggggdfsdf', + authResult: { + access_token: 'access_tokenTEST', + id_token: '', + }, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + existingIdToken: null, + }; + + const stateObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + stateObs$.subscribe((state) => { + expect(state.accessToken).toBe('access_tokenTEST'); + expect(state.idToken).toBe(''); + expect(state.decodedIdToken).toBeDefined(); + expect(state.authResponseIsValid).toBe(true); + }); + })); + + it('should return OK if disableIdTokenValidation is true', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + + config.responseType = 'id_token token'; + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + config.disableIdTokenValidation = true; + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: null, + authResult: {}, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const isValidObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + isValidObs$.subscribe((isValid) => { + expect(isValid.state).toBe(ValidationResult.Ok); + expect(isValid.authResponseIsValid).toBe(true); + }); + })); + + it('should return OK if disableIdTokenValidation is true', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + + config.responseType = 'id_token token'; + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + config.disableIdTokenValidation = true; + + const callbackContext: CallbackContext = { + code: 'fdffsdfsdf', + refreshToken: '', + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: '', + authResult: {}, + isRenewProcess: false, + jwtKeys: null, + validationResult: null, + }; + + const isValidObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + isValidObs$.subscribe((isValid) => { + expect(isValid.state).toBe(ValidationResult.Ok); + expect(isValid.authResponseIsValid).toBe(true); + }); + })); + + it('should return OK if disableIdTokenValidation is false but inrefreshtokenflow and no id token is returned', waitForAsync(() => { + spyOn( + tokenValidationService, + 'validateStateFromHashCallback' + ).and.returnValue(true); + spyOn( + flowHelper, + 'isCurrentFlowImplicitFlowWithAccessToken' + ).and.returnValue(false); + spyOn(flowHelper, 'isCurrentFlowCodeFlow').and.returnValue(false); + + config.responseType = 'id_token token'; + config.maxIdTokenIatOffsetAllowedInSeconds = 0; + config.disableIdTokenValidation = false; + + const callbackContext = { + code: 'fdffsdfsdf', + refreshToken: 'something', + state: 'fdffsggggggdfsdf', + sessionState: 'fdffsggggggdfsdf', + existingIdToken: null, + authResult: {}, + isRenewProcess: true, + jwtKeys: null, + validationResult: null, + }; + + const isValidObs$ = stateValidationService.getValidatedStateResult( + callbackContext, + config + ); + + isValidObs$.subscribe((isValid) => { + expect(isValid.state).toBe(ValidationResult.Ok); + expect(isValid.authResponseIsValid).toBe(true); + }); + })); + }); +}); diff --git a/src/validation/state-validation.service.ts b/src/validation/state-validation.service.ts new file mode 100644 index 0000000..318c519 --- /dev/null +++ b/src/validation/state-validation.service.ts @@ -0,0 +1,539 @@ +import { inject, Injectable } from 'injection-js'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { CallbackContext } from '../flows/callback-context'; +import { LoggerService } from '../logging/logger.service'; +import { StoragePersistenceService } from '../storage/storage-persistence.service'; +import { EqualityService } from '../utils/equality/equality.service'; +import { FlowHelper } from '../utils/flowHelper/flow-helper.service'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { StateValidationResult } from './state-validation-result'; +import { TokenValidationService } from './token-validation.service'; +import { ValidationResult } from './validation-result'; + +@Injectable() +export class StateValidationService { + private readonly storagePersistenceService = inject( + StoragePersistenceService + ); + + private readonly tokenValidationService = inject(TokenValidationService); + + private readonly tokenHelperService = inject(TokenHelperService); + + private readonly loggerService = inject(LoggerService); + + private readonly equalityService = inject(EqualityService); + + private readonly flowHelper = inject(FlowHelper); + + getValidatedStateResult( + callbackContext: CallbackContext, + configuration: OpenIdConfiguration + ): Observable { + const hasError = Boolean(callbackContext.authResult?.error); + const hasCallbackContext = Boolean(callbackContext); + + if (!hasCallbackContext || hasError) { + return of(new StateValidationResult('', '', false, {})); + } + + return this.validateState(callbackContext, configuration); + } + + private validateState( + callbackContext: CallbackContext, + configuration: OpenIdConfiguration + ): Observable { + const toReturn = new StateValidationResult(); + const authStateControl = this.storagePersistenceService.read( + 'authStateControl', + configuration + ); + + if ( + !this.tokenValidationService.validateStateFromHashCallback( + callbackContext.authResult?.state, + authStateControl, + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect state' + ); + toReturn.state = ValidationResult.StatesDoNotMatch; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + const isCurrentFlowImplicitFlowWithAccessToken = + this.flowHelper.isCurrentFlowImplicitFlowWithAccessToken(configuration); + const isCurrentFlowCodeFlow = + this.flowHelper.isCurrentFlowCodeFlow(configuration); + + if (isCurrentFlowImplicitFlowWithAccessToken || isCurrentFlowCodeFlow) { + toReturn.accessToken = callbackContext.authResult?.access_token ?? ''; + } + + const disableIdTokenValidation = configuration.disableIdTokenValidation; + + if (disableIdTokenValidation) { + toReturn.state = ValidationResult.Ok; + toReturn.authResponseIsValid = true; + + return of(toReturn); + } + + const isInRefreshTokenFlow = + callbackContext.isRenewProcess && !!callbackContext.refreshToken; + const hasIdToken = Boolean(callbackContext.authResult?.id_token); + + if (isInRefreshTokenFlow && !hasIdToken) { + toReturn.state = ValidationResult.Ok; + toReturn.authResponseIsValid = true; + + return of(toReturn); + } + + if (hasIdToken) { + const { + clientId, + issValidationOff, + maxIdTokenIatOffsetAllowedInSeconds, + disableIatOffsetValidation, + ignoreNonceAfterRefresh, + renewTimeBeforeTokenExpiresInSeconds, + } = configuration; + + toReturn.idToken = callbackContext.authResult?.id_token ?? ''; + toReturn.decodedIdToken = this.tokenHelperService.getPayloadFromToken( + toReturn.idToken, + false, + configuration + ); + + return this.tokenValidationService + .validateSignatureIdToken( + toReturn.idToken, + callbackContext.jwtKeys, + configuration + ) + .pipe( + mergeMap((isSignatureIdTokenValid: boolean) => { + if (!isSignatureIdTokenValid) { + this.loggerService.logDebug( + configuration, + 'authCallback Signature validation failed id_token' + ); + toReturn.state = ValidationResult.SignatureFailed; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + const authNonce = this.storagePersistenceService.read( + 'authNonce', + configuration + ); + + if ( + !this.tokenValidationService.validateIdTokenNonce( + toReturn.decodedIdToken, + authNonce, + Boolean(ignoreNonceAfterRefresh), + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect nonce, did you call the checkAuth() method multiple times?' + ); + toReturn.state = ValidationResult.IncorrectNonce; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !this.tokenValidationService.validateRequiredIdToken( + toReturn.decodedIdToken, + configuration + ) + ) { + this.loggerService.logDebug( + configuration, + 'authCallback Validation, one of the REQUIRED properties missing from id_token' + ); + toReturn.state = ValidationResult.RequiredPropertyMissing; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !isInRefreshTokenFlow && + !this.tokenValidationService.validateIdTokenIatMaxOffset( + toReturn.decodedIdToken, + maxIdTokenIatOffsetAllowedInSeconds ?? 120, + Boolean(disableIatOffsetValidation), + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback Validation, iat rejected id_token was issued too far away from the current time' + ); + toReturn.state = ValidationResult.MaxOffsetExpired; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + const authWellKnownEndPoints = this.storagePersistenceService.read( + 'authWellKnownEndPoints', + configuration + ); + + if (authWellKnownEndPoints) { + if (issValidationOff) { + this.loggerService.logDebug( + configuration, + 'iss validation is turned off, this is not recommended!' + ); + } else if ( + !issValidationOff && + !this.tokenValidationService.validateIdTokenIss( + toReturn.decodedIdToken, + authWellKnownEndPoints.issuer, + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect iss does not match authWellKnownEndpoints issuer' + ); + toReturn.state = ValidationResult.IssDoesNotMatchIssuer; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + } else { + this.loggerService.logWarning( + configuration, + 'authWellKnownEndpoints is undefined' + ); + toReturn.state = ValidationResult.NoAuthWellKnownEndPoints; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !this.tokenValidationService.validateIdTokenAud( + toReturn.decodedIdToken, + clientId, + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect aud' + ); + toReturn.state = ValidationResult.IncorrectAud; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !this.tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud( + toReturn.decodedIdToken + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback missing azp' + ); + toReturn.state = ValidationResult.IncorrectAzp; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !this.tokenValidationService.validateIdTokenAzpValid( + toReturn.decodedIdToken, + clientId + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect azp' + ); + toReturn.state = ValidationResult.IncorrectAzp; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !this.isIdTokenAfterRefreshTokenRequestValid( + callbackContext, + toReturn.decodedIdToken, + configuration + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback pre, post id_token claims do not match in refresh' + ); + toReturn.state = + ValidationResult.IncorrectIdTokenClaimsAfterRefresh; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + if ( + !isInRefreshTokenFlow && + !this.tokenValidationService.validateIdTokenExpNotExpired( + toReturn.decodedIdToken, + configuration, + renewTimeBeforeTokenExpiresInSeconds + ) + ) { + this.loggerService.logWarning( + configuration, + 'authCallback id token expired' + ); + toReturn.state = ValidationResult.TokenExpired; + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + return this.validateDefault( + isCurrentFlowImplicitFlowWithAccessToken, + isCurrentFlowCodeFlow, + toReturn, + configuration, + callbackContext + ); + }) + ); + } else { + this.loggerService.logDebug( + configuration, + 'No id_token found, skipping id_token validation' + ); + } + + return this.validateDefault( + isCurrentFlowImplicitFlowWithAccessToken, + isCurrentFlowCodeFlow, + toReturn, + configuration, + callbackContext + ); + } + + private validateDefault( + isCurrentFlowImplicitFlowWithAccessToken: boolean, + isCurrentFlowCodeFlow: boolean, + toReturn: StateValidationResult, + configuration: OpenIdConfiguration, + callbackContext: CallbackContext + ): Observable { + // flow id_token + if (!isCurrentFlowImplicitFlowWithAccessToken && !isCurrentFlowCodeFlow) { + toReturn.authResponseIsValid = true; + toReturn.state = ValidationResult.Ok; + this.handleSuccessfulValidation(configuration); + this.handleUnsuccessfulValidation(configuration); + + return of(toReturn); + } + + // only do check if id_token returned, no always the case when using refresh tokens + if (callbackContext.authResult?.id_token) { + const idTokenHeader = this.tokenHelperService.getHeaderFromToken( + toReturn.idToken, + false, + configuration + ); + + if ( + isCurrentFlowCodeFlow && + !(toReturn.decodedIdToken.at_hash as string) + ) { + this.loggerService.logDebug( + configuration, + 'Code Flow active, and no at_hash in the id_token, skipping check!' + ); + } else { + return this.tokenValidationService + .validateIdTokenAtHash( + toReturn.accessToken, + toReturn.decodedIdToken.at_hash, + idTokenHeader.alg, // 'RS256' + configuration + ) + .pipe( + map((valid: boolean) => { + if (!valid || !toReturn.accessToken) { + this.loggerService.logWarning( + configuration, + 'authCallback incorrect at_hash' + ); + toReturn.state = ValidationResult.IncorrectAtHash; + this.handleUnsuccessfulValidation(configuration); + + return toReturn; + } else { + toReturn.authResponseIsValid = true; + toReturn.state = ValidationResult.Ok; + this.handleSuccessfulValidation(configuration); + + return toReturn; + } + }) + ); + } + } + + toReturn.authResponseIsValid = true; + toReturn.state = ValidationResult.Ok; + this.handleSuccessfulValidation(configuration); + + return of(toReturn); + } + + private isIdTokenAfterRefreshTokenRequestValid( + callbackContext: CallbackContext, + newIdToken: any, + configuration: OpenIdConfiguration + ): boolean { + const { useRefreshToken, disableRefreshIdTokenAuthTimeValidation } = + configuration; + + if (!useRefreshToken) { + return true; + } + + if (!callbackContext.existingIdToken) { + return true; + } + + const decodedIdToken = this.tokenHelperService.getPayloadFromToken( + callbackContext.existingIdToken, + false, + configuration + ); + + // Upon successful validation of the Refresh Token, the response body is the Token Response of Section 3.1.3.3 + // except that it might not contain an id_token. + + // If an ID Token is returned as a result of a token refresh request, the following requirements apply: + + // its iss Claim Value MUST be the same as in the ID Token issued when the original authentication occurred, + if (decodedIdToken.iss !== newIdToken.iss) { + this.loggerService.logDebug( + configuration, + `iss do not match: ${decodedIdToken.iss} ${newIdToken.iss}` + ); + + return false; + } + // its azp Claim Value MUST be the same as in the ID Token issued when the original authentication occurred; + // if no azp Claim was present in the original ID Token, one MUST NOT be present in the new ID Token, and + // otherwise, the same rules apply as apply when issuing an ID Token at the time of the original authentication. + if (decodedIdToken.azp !== newIdToken.azp) { + this.loggerService.logDebug( + configuration, + `azp do not match: ${decodedIdToken.azp} ${newIdToken.azp}` + ); + + return false; + } + // its sub Claim Value MUST be the same as in the ID Token issued when the original authentication occurred, + if (decodedIdToken.sub !== newIdToken.sub) { + this.loggerService.logDebug( + configuration, + `sub do not match: ${decodedIdToken.sub} ${newIdToken.sub}` + ); + + return false; + } + + // its aud Claim Value MUST be the same as in the ID Token issued when the original authentication occurred, + if ( + !this.equalityService.isStringEqualOrNonOrderedArrayEqual( + decodedIdToken?.aud, + newIdToken?.aud + ) + ) { + this.loggerService.logDebug( + configuration, + `aud in new id_token is not valid: '${decodedIdToken?.aud}' '${newIdToken.aud}'` + ); + + return false; + } + + if (disableRefreshIdTokenAuthTimeValidation) { + return true; + } + + // its iat Claim MUST represent the time that the new ID Token is issued, + // if the ID Token contains an auth_time Claim, its value MUST represent the time of the original authentication + // - not the time that the new ID token is issued, + if (decodedIdToken.auth_time !== newIdToken.auth_time) { + this.loggerService.logDebug( + configuration, + `auth_time do not match: ${decodedIdToken.auth_time} ${newIdToken.auth_time}` + ); + + return false; + } + + return true; + } + + private handleSuccessfulValidation(configuration: OpenIdConfiguration): void { + const { autoCleanStateAfterAuthentication } = configuration; + + this.storagePersistenceService.write('authNonce', null, configuration); + + if (autoCleanStateAfterAuthentication) { + this.storagePersistenceService.write( + 'authStateControl', + '', + configuration + ); + } + this.loggerService.logDebug( + configuration, + 'authCallback token(s) validated, continue' + ); + } + + private handleUnsuccessfulValidation( + configuration: OpenIdConfiguration + ): void { + const { autoCleanStateAfterAuthentication } = configuration; + + this.storagePersistenceService.write('authNonce', null, configuration); + + if (autoCleanStateAfterAuthentication) { + this.storagePersistenceService.write( + 'authStateControl', + '', + configuration + ); + } + this.loggerService.logDebug(configuration, 'authCallback token(s) invalid'); + } +} diff --git a/src/validation/token-validation.helper.spec.ts b/src/validation/token-validation.helper.spec.ts new file mode 100644 index 0000000..194d777 --- /dev/null +++ b/src/validation/token-validation.helper.spec.ts @@ -0,0 +1,159 @@ +import { alg2kty, getImportAlg, getVerifyAlg } from './token-validation.helper'; + +describe('getVerifyAlg', () => { + it('returns null if char has no E or R', () => { + const algorithm = 'ASDFGT'; + + const result = getVerifyAlg(algorithm); + + expect(result).toBe(null); + }); + + it('returns correct result when algorithm is R', () => { + const algorithm = 'R'; + + const result = getVerifyAlg(algorithm); + + expect(result).toEqual({ + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + }); + + it('returns null if algorithm is only E', () => { + const algorithm = 'E'; + + const result = getVerifyAlg(algorithm); + + expect(result).toBe(null); + }); + + it('returns correct result if algorithm is E256', () => { + const algorithm = 'E256'; + + const result = getVerifyAlg(algorithm); + + expect(result).toEqual({ + name: 'ECDSA', + hash: 'SHA-256', + }); + }); + + it('returns correct result if algorithm is E384', () => { + const algorithm = 'E384'; + + const result = getVerifyAlg(algorithm); + + expect(result).toEqual({ + name: 'ECDSA', + hash: 'SHA-384', + }); + }); +}); + +describe('alg2kty', () => { + it('returns correct result if algorithm is R', () => { + const algorithm = 'R'; + + const result = alg2kty(algorithm); + + expect(result).toEqual('RSA'); + }); + + it('returns correct result if algorithm is E', () => { + const algorithm = 'E'; + + const result = alg2kty(algorithm); + + expect(result).toEqual('EC'); + }); + + it('returns correct result if algorithm is E', () => { + const algorithm = 'SOMETHING_ELSE'; + + expect(() => alg2kty(algorithm)).toThrow( + new Error('Cannot infer kty from alg: SOMETHING_ELSE') + ); + }); +}); + +describe('getImportAlg', () => { + it('returns null if algorithm is not R or E', () => { + const algorithm = 'Q'; + + const result = getImportAlg(algorithm); + + expect(result).toBe(null); + }); + + it('returns null if algorithm is only R', () => { + const algorithm = 'R'; + + const result = getImportAlg(algorithm); + + expect(result).toBe(null); + }); + + it('returns correct result if algorithm is R256', () => { + const algorithm = 'R256'; + + const result = getImportAlg(algorithm); + + expect(result).toEqual({ + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + }); + + it('returns correct result if algorithm is R384', () => { + const algorithm = 'R384'; + + const result = getImportAlg(algorithm); + + expect(result).toEqual({ + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-384', + }); + }); + + it('returns correct result if algorithm is R512', () => { + const algorithm = 'R512'; + + const result = getImportAlg(algorithm); + + expect(result).toEqual({ + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-512', + }); + }); + + it('returns null if algorithm is only E', () => { + const algorithm = 'E'; + + const result = getImportAlg(algorithm); + + expect(result).toBe(null); + }); + + it('returns correct result if algorithm is E256', () => { + const algorithm = 'E256'; + + const result = getImportAlg(algorithm); + + expect(result).toEqual({ + name: 'ECDSA', + namedCurve: 'P-256', + }); + }); + + it('returns correct result if algorithm is E384', () => { + const algorithm = 'E384'; + + const result = getImportAlg(algorithm); + + expect(result).toEqual({ + name: 'ECDSA', + namedCurve: 'P-384', + }); + }); +}); diff --git a/src/validation/token-validation.helper.ts b/src/validation/token-validation.helper.ts new file mode 100644 index 0000000..4bc993c --- /dev/null +++ b/src/validation/token-validation.helper.ts @@ -0,0 +1,82 @@ +export function getVerifyAlg( + alg: string +): RsaHashedImportParams | EcdsaParams | null { + switch (alg.charAt(0)) { + case 'R': + return { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }; + case 'E': + if (alg.includes('256')) { + return { + name: 'ECDSA', + hash: 'SHA-256', + }; + } else if (alg.includes('384')) { + return { + name: 'ECDSA', + hash: 'SHA-384', + }; + } else { + return null; + } + default: + return null; + } +} + +export function alg2kty(alg: string): string { + switch (alg.charAt(0)) { + case 'R': + return 'RSA'; + + case 'E': + return 'EC'; + + default: + throw new Error('Cannot infer kty from alg: ' + alg); + } +} + +export function getImportAlg( + alg: string +): RsaHashedImportParams | EcKeyImportParams | null { + switch (alg.charAt(0)) { + case 'R': + if (alg.includes('256')) { + return { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }; + } else if (alg.includes('384')) { + return { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-384', + }; + } else if (alg.includes('512')) { + return { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-512', + }; + } else { + return null; + } + case 'E': + if (alg.includes('256')) { + return { + name: 'ECDSA', + namedCurve: 'P-256', + }; + } else if (alg.includes('384')) { + return { + name: 'ECDSA', + namedCurve: 'P-384', + }; + } else { + return null; + } + default: + return null; + } +} diff --git a/src/validation/token-validation.service.spec.ts b/src/validation/token-validation.service.spec.ts new file mode 100644 index 0000000..f2abacb --- /dev/null +++ b/src/validation/token-validation.service.spec.ts @@ -0,0 +1,862 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { mockProvider } from '../../test/auto-mock'; +import { JwkExtractor } from '../extractors/jwk.extractor'; +import { LoggerService } from '../logging/logger.service'; +import { CryptoService } from '../utils/crypto/crypto.service'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { JwkWindowCryptoService } from './jwk-window-crypto.service'; +import { JwtWindowCryptoService } from './jwt-window-crypto.service'; +import { TokenValidationService } from './token-validation.service'; + +describe('TokenValidationService', () => { + let tokenValidationService: TokenValidationService; + let tokenHelperService: TokenHelperService; + let jwtWindowCryptoService: JwtWindowCryptoService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + TokenValidationService, + mockProvider(LoggerService), + mockProvider(TokenHelperService), + JwkExtractor, + JwkWindowCryptoService, + JwtWindowCryptoService, + CryptoService, + ], + }); + }); + + beforeEach(() => { + tokenValidationService = TestBed.inject(TokenValidationService); + tokenHelperService = TestBed.inject(TokenHelperService); + jwtWindowCryptoService = TestBed.inject(JwtWindowCryptoService); + }); + + it('should create', () => { + expect(tokenValidationService).toBeTruthy(); + }); + + describe('validateIdTokenAud', () => { + it('returns true if aud is string and passed aud matches idToken.aud', () => { + const dataIdToken = { aud: 'banana' }; + const valueTrue = tokenValidationService.validateIdTokenAud( + dataIdToken, + 'banana', + { configId: 'configId1' } + ); + + expect(valueTrue).toEqual(true); + }); + + it('returns false if aud is string and passed aud does not match idToken.aud', () => { + const dataIdToken = { aud: 'banana' }; + + const valueFalse = tokenValidationService.validateIdTokenAud( + dataIdToken, + 'bananammmm', + { configId: 'configId1' } + ); + + expect(valueFalse).toEqual(false); + }); + + it('returns true if aud is string array and passed aud is included in the array', () => { + const dataIdToken = { + aud: ['banana', 'apple', 'https://nice.dom'], + }; + + const audValidTrue = tokenValidationService.validateIdTokenAud( + dataIdToken, + 'apple', + { configId: 'configId1' } + ); + + expect(audValidTrue).toEqual(true); + }); + + it('returns false if aud is string array and passed aud is NOT included in the array', () => { + const dataIdToken = { + aud: ['banana', 'apple', 'https://nice.dom'], + }; + + const audValidFalse = tokenValidationService.validateIdTokenAud( + dataIdToken, + 'https://nice.domunnnnnnkoem', + { + configId: 'configId1', + } + ); + + expect(audValidFalse).toEqual(false); + }); + }); + + describe('validateIdTokenNonce', () => { + it('should validate id token nonce after code grant when match', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: 'test1' }, + 'test1', + false, + { configId: 'configId1' } + ) + ).toBe(true); + }); + + it('should not validate id token nonce after code grant when no match', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: 'test1' }, + 'test2', + false, + { configId: 'configId1' } + ) + ).toBe(false); + }); + + it('should validate id token nonce after refresh token grant when undefined and no ignore', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: undefined }, + TokenValidationService.refreshTokenNoncePlaceholder, + false, + { + configId: 'configId1', + } + ) + ).toBe(true); + }); + + it('should validate id token nonce after refresh token grant when undefined and ignore', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: undefined }, + TokenValidationService.refreshTokenNoncePlaceholder, + true, + { + configId: 'configId1', + } + ) + ).toBe(true); + }); + + it('should validate id token nonce after refresh token grant when defined and ignore', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: 'test1' }, + TokenValidationService.refreshTokenNoncePlaceholder, + true, + { + configId: 'configId1', + } + ) + ).toBe(true); + }); + + it('should not validate id token nonce after refresh token grant when defined and no ignore', () => { + expect( + tokenValidationService.validateIdTokenNonce( + { nonce: 'test1' }, + TokenValidationService.refreshTokenNoncePlaceholder, + false, + { + configId: 'configId1', + } + ) + ).toBe(false); + }); + }); + + describe('validateIdTokenAzpExistsIfMoreThanOneAud', () => { + it('returns false if aud is array, öength is bigger than 1 and has no azp property', () => { + const dataIdToken = { + aud: ['one', 'two'], + }; + const result = + tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud( + dataIdToken + ); + + expect(result).toBe(false); + }); + + it('returns false if aud is array, ength is bigger than 1 and has no azp property', () => { + const result = + tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud(null); + + expect(result).toBe(false); + }); + }); + + describe('validateIdTokenAzpValid', () => { + it('returns true dataIdToken param is null', () => { + const result = tokenValidationService.validateIdTokenAzpValid(null, ''); + + expect(result).toBe(true); + }); + + it('returns false when aud is an array and client id is NOT in the aud array', () => { + const dataIdToken = { + aud: [ + 'banana', + 'apple', + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + ], + azp: '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + }; + + const azpInvalid = tokenValidationService.validateIdTokenAzpValid( + dataIdToken, + 'bananammmm' + ); + + expect(azpInvalid).toEqual(false); + }); + + it('returns true when aud is an array and client id is in the aud array', () => { + const dataIdToken = { + aud: [ + 'banana', + 'apple', + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + ], + azp: '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + }; + + const azpValid = tokenValidationService.validateIdTokenAzpValid( + dataIdToken, + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com' + ); + + expect(azpValid).toEqual(true); + }); + + it('returns true if ID token has no azp property', () => { + const dataIdToken = { + noAzpProperty: 'something', + }; + + const azpValid = tokenValidationService.validateIdTokenAzpValid( + dataIdToken, + 'bananammmm' + ); + + expect(azpValid).toEqual(true); + }); + }); + + describe('validateIdTokenAzpExistsIfMoreThanOneAud', () => { + it('returns true if aud is array and aud contains azp', () => { + const dataIdToken = { + aud: [ + 'banana', + 'apple', + '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + ], + azp: '188968487735-b1hh7k87nkkh6vv84548sinju2kpr7gn.apps.googleusercontent.com', + }; + + const valueTrue = + tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud( + dataIdToken + ); + + expect(valueTrue).toEqual(true); + }); + + it('returns true if aud is array but only has one item', () => { + const dataIdToken = { + aud: ['banana'], + }; + + const valueTrue = + tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud( + dataIdToken + ); + + expect(valueTrue).toEqual(true); + }); + + it('returns true if aud is NOT an array', () => { + const dataIdToken = { + aud: 'banana', + }; + const valueTrue = + tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud( + dataIdToken + ); + + expect(valueTrue).toEqual(true); + }); + }); + + describe('validateRequiredIdToken', () => { + it('returns false if property iat is missing', () => { + const decodedIdToken = { + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + exp: 1589210086, + // iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + + it('returns false if property exp is missing', () => { + const decodedIdToken = { + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + // exp: 1589210086, + iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + + it('returns false if property aud is missing', () => { + const decodedIdToken = { + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + // aud: 'bad', + exp: 1589210086, + iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + + it('returns false if property sub is missing', () => { + const decodedIdToken = { + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + // sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + exp: 1589210086, + iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + + it('returns false if property iss is missing', () => { + const decodedIdToken = { + // iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + exp: 1589210086, + iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + + it('returns true if all is valid', () => { + const decodedIdToken = { + iss: 'https://damienbod.b2clogin.com/a0958f45-195b-4036-9259-de2f7e594db6/v2.0/', + sub: 'f836f380-3c64-4802-8dbc-011981c068f5', + aud: 'bad', + exp: 1589210086, + iat: 1589206486, + }; + + const result = tokenValidationService.validateRequiredIdToken( + decodedIdToken, + { configId: 'configId1' } + ); + + expect(result).toEqual(true); + }); + }); + + describe('validateIdTokenIss', () => { + it('returns true if issuer matches iss in idToken', () => { + const decodedIdToken = { + iss: 'xc', + }; + + const valueTrue = tokenValidationService.validateIdTokenIss( + decodedIdToken, + 'xc', + { configId: 'configId1' } + ); + + expect(valueTrue).toEqual(true); + }); + + it('returns false if issuer does not match iss in idToken', () => { + const decodedIdToken = { + iss: 'xc', + }; + + const valueFalse = tokenValidationService.validateIdTokenIss( + decodedIdToken, + 'xcjjjj', + { configId: 'configId1' } + ); + + expect(valueFalse).toEqual(false); + }); + }); + + describe('validateIdTokenIatMaxOffset', () => { + it('returns true if validationIsDisabled', () => { + const result = tokenValidationService.validateIdTokenIatMaxOffset( + null, + 0, + true, + { configId: 'configId1' } + ); + + expect(result).toBe(true); + }); + + it('returns false if dataIdToken has no property "iat"', () => { + const dataIdToken = { + notIat: 'test', + }; + const result = tokenValidationService.validateIdTokenIatMaxOffset( + dataIdToken, + 0, + false, + { configId: 'configId1' } + ); + + expect(result).toBe(false); + }); + + it('returns true if time is Mon Jan 19 1970 10:26:46 GMT+0100, and the offset is big like 500000000000 seconds', () => { + const decodedIdToken = { + iat: 1589206486, // Mon Jan 19 1970 10:26:46 GMT+0100 (Central European Standard Time) + }; + + const valueTrue = tokenValidationService.validateIdTokenIatMaxOffset( + decodedIdToken, + 500000000000, + false, + { configId: 'configId1' } + ); + + expect(valueTrue).toEqual(true); + }); + + it('returns false if time is Sat Nov 09 1985 02:47:57 GMT+0100, and the offset is 0 seconds', () => { + const decodedIdTokenNegIat = { + iat: 500348877430, // Sat Nov 09 1985 02:47:57 GMT+0100 (Central European Standard Time) + }; + const valueFalseNeg = tokenValidationService.validateIdTokenIatMaxOffset( + decodedIdTokenNegIat, + 0, + false, + { configId: 'configId1' } + ); + + expect(valueFalseNeg).toEqual(false); + }); + + it('returns true if time is Mon Jan 19 1970 10:26:46 GMT+0100, and the offset is small like 5 seconds', () => { + const decodedIdToken = { + iat: 1589206486, // Mon Jan 19 1970 10:26:46 GMT+0100 (Central European Standard Time) + }; + const valueFalse = tokenValidationService.validateIdTokenIatMaxOffset( + decodedIdToken, + 5, + false, + { configId: 'configId1' } + ); + + expect(valueFalse).toEqual(false); + }); + }); + + describe('validateSignatureIdToken', () => { + it('returns false if no kwtKeys are passed', waitForAsync(() => { + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + 'some-id-token', + null, + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(false); + }); + })); + + it('returns true if no idToken is passed', waitForAsync(() => { + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + null as any, + 'some-jwt-keys', + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(true); + }); + })); + + it('returns false if jwtkeys has no keys-property', waitForAsync(() => { + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + 'some-id-token', + { notKeys: '' }, + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(false); + }); + })); + + it('returns false if header data has no header data', waitForAsync(() => { + spyOn(tokenHelperService, 'getHeaderFromToken').and.returnValue({}); + + const jwtKeys = { + keys: 'someThing', + }; + + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + 'some-id-token', + jwtKeys, + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(false); + }); + })); + + it('returns false if header data alg property does not exist in keyalgorithms', waitForAsync(() => { + spyOn(tokenHelperService, 'getHeaderFromToken').and.returnValue({ + alg: 'NOT SUPPORTED ALG', + }); + + const jwtKeys = { + keys: 'someThing', + }; + + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + 'some-id-token', + jwtKeys, + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(false); + }); + })); + + it('returns false if header data has kid property and jwtKeys has same kid property but they are not valid with the token', (done) => { + const kid = '5626CE6A8F4F5FCD79C6642345282CA76D337548'; + + spyOn(tokenHelperService, 'getHeaderFromToken').and.returnValue({ + alg: 'RS256', + kid, + }); + spyOn(tokenHelperService, 'getSignatureFromToken').and.returnValue(''); + + const jwtKeys = { + keys: [ + { + kty: 'RSA', + use: 'sig', + kid, + x5t: 'VibOao9PX815xmQjRSgsp20zdUg', + e: 'AQAB', + n: 'uu3-HK4pLRHJHoEBzFhM516RWx6nybG5yQjH4NbKjfGQ8dtKy1BcGjqfMaEKF8KOK44NbAx7rtBKCO9EKNYkeFvcUzBzVeuu4jWG61XYdTekgv-Dh_Fj8245GocEkbvBbFW6cw-_N59JWqUuiCvb-EOfhcuubUcr44a0AQyNccYNpcXGRcMKy7_L1YhO0AMULqLDDVLFj5glh4TcJ2N5VnJedq1-_JKOxPqD1ni26UOQoWrW16G29KZ1_4Xxf2jX8TAq-4RJEHccdzgZVIO4F5B4MucMZGq8_jMCpiTUsUGDOAMA_AmjxIRHOtO5n6Pt0wofrKoAVhGh2sCTtaQf2Q', + x5c: [ + 'MIIDPzCCAiegAwIBAgIQF+HRVxLHII9IlOoQk6BxcjANBgkqhkiG9w0BAQsFADAbMRkwFwYDVQQDDBBzdHMuZGV2LmNlcnQuY29tMB4XDTE5MDIyMDEwMTA0M1oXDTM5MDIyMDEwMTkyOVowGzEZMBcGA1UEAwwQc3RzLmRldi5jZXJ0LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALrt/hyuKS0RyR6BAcxYTOdekVsep8mxuckIx+DWyo3xkPHbSstQXBo6nzGhChfCjiuODWwMe67QSgjvRCjWJHhb3FMwc1XrruI1hutV2HU3pIL/g4fxY/NuORqHBJG7wWxVunMPvzefSVqlLogr2/hDn4XLrm1HK+OGtAEMjXHGDaXFxkXDCsu/y9WITtADFC6iww1SxY+YJYeE3CdjeVZyXnatfvySjsT6g9Z4tulDkKFq1tehtvSmdf+F8X9o1/EwKvuESRB3HHc4GVSDuBeQeDLnDGRqvP4zAqYk1LFBgzgDAPwJo8SERzrTuZ+j7dMKH6yqAFYRodrAk7WkH9kCAwEAAaN/MH0wDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAtBgNVHREEJjAkghBzdHMuZGV2LmNlcnQuY29tghBzdHMuZGV2LmNlcnQuY29tMB0GA1UdDgQWBBQuyHxWP3je6jGMOmOiY+hz47r36jANBgkqhkiG9w0BAQsFAAOCAQEAKEHG7Ga6nb2XiHXDc69KsIJwbO80+LE8HVJojvITILz3juN6/FmK0HmogjU6cYST7m1MyxsVhQQNwJASZ6haBNuBbNzBXfyyfb4kr62t1oDLNwhctHaHaM4sJSf/xIw+YO+Qf7BtfRAVsbM05+QXIi2LycGrzELiXu7KFM0E1+T8UOZ2Qyv7OlCb/pWkYuDgE4w97ox0MhDpvgluxZLpRanOLUCVGrfFaij7gRAhjYPUY3vAEcD8JcFBz1XijU8ozRO6FaG4qg8/JCe+VgoWsMDj3sKB9g0ob6KCyG9L2bdk99PGgvXDQvMYCpkpZzG3XsxOINPd5p0gc209ZOoxTg==', + ], + alg: 'RS256', + }, + ], + }; + + const valueFalse$ = tokenValidationService.validateSignatureIdToken( + 'someNOTMATCHINGIdToken', + jwtKeys, + { configId: 'configId1' } + ); + + valueFalse$.subscribe((valueFalse) => { + expect(valueFalse).toEqual(false); + done(); + }); + }); + + it('should return true if valid input is provided', (done) => { + const idToken = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzNDU2IiwiYXVkIjoibXlfY2xpZW50X2lkIiwiZXhwIjoxMzExMjgxOTcwLCJpYXQiOjEzMTEyODA5NzAsIm5hbWUiOiJKYW5lIERvZSIsImdpdmVuX25hbWUiOiJKYW5lIiwiZmFtaWx5X25hbWUiOiJEb2UiLCJiaXJ0aGRhdGUiOiIxOTkwLTEwLTMxIiwiZW1haWwiOiJqYW5lZG9lQGV4YW1wbGUuY29tIiwicGljdHVyZSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vamFuZWRvZS9tZS5qcGcifQ.SY0ilps7yKYmYCc41zNOatfmAFhOtDYwuIT80qrHMl_4FEO2WFWSv-aDl4QfTSKY9A6MMP6xy0Z_8Kk7NeRwIV7FVScMLnPvVzs9pxza0e_rl6hmZLb5P5n4AEINwn46X9XmRB5W3EZO_x2LG65_g3NZFiPrzOC1Fs_6taJl7TfI8lOveYDoJyXCWYQMS3Oh5MM9S8W-Hc29_qJLH-kixm1S01qoICRPDGMRwhtAu1DHjwWQp9Ycfz6g3uyb7N1imBvI49t1CwWy02_mQ3g-7e7bOP1Ax2kgrwnJgsVBDULnyCZG9PE8T0CHZl_fErZtvbJJ0jdoZ1fyr48906am2w'; + const idTokenParts = idToken.split('.'); + const key = { + kty: 'RSA', + n: 'u1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0_IzW7yWR7QkrmBL7jTKEn5u-qKhbwKfBstIs-bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW_VDL5AaWTg0nLVkjRo9z-40RQzuVaE8AkAFmxZzow3x-VJYKdjykkJ0iT9wCS0DRTXu269V264Vf_3jvredZiKRkgwlL9xNAwxXFg0x_XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC-9aGVd-Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmw', + e: 'AQAB', + alg: 'RS256', + kid: 'boop', + use: 'sig', + }; + const jwtKeys = { + keys: [key], + }; + + spyOn(tokenHelperService, 'getHeaderFromToken').and.returnValue({ + alg: 'RS256', + typ: 'JWT', + }); + spyOn(tokenHelperService, 'getSigningInputFromToken').and.returnValue( + [idTokenParts[0], idTokenParts[1]].join('.') + ); + spyOn(tokenHelperService, 'getSignatureFromToken').and.returnValue( + idTokenParts[2] + ); + + const valueTrue$ = tokenValidationService.validateSignatureIdToken( + idToken, + jwtKeys, + { configId: 'configId1' } + ); + + valueTrue$.subscribe((valueTrue) => { + expect(valueTrue).toEqual(true); + done(); + }); + }); + }); + + describe('validateIdTokenAtHash', () => { + it('returns true if sha is sha256 and generated hash equals atHash param', (done) => { + const accessToken = 'iGU3DhbPoDljiYtr0oepxi7zpT8BsjdU7aaXcdq-DPk'; + const atHash = '-ODC_7Go_UIUTC8nP4k2cA'; + + const result$ = tokenValidationService.validateIdTokenAtHash( + accessToken, + atHash, + '256', + { configId: 'configId1' } + ); + + result$.subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + + it('returns false if sha is sha256 and generated hash does not equal atHash param', (done) => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const atHash = 'bad'; + + const result$ = tokenValidationService.validateIdTokenAtHash( + accessToken, + atHash, + '256', + { configId: 'configId1' } + ); + + result$.subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + + it('returns true if sha is sha256 and generated hash does equal atHash param', (done) => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const atHash = 'good'; + + spyOn(jwtWindowCryptoService, 'generateAtHash').and.returnValues( + of('notEqualsGood'), + of('good') + ); + + const result$ = tokenValidationService.validateIdTokenAtHash( + accessToken, + atHash, + '256', + { configId: 'configId1' } + ); + + result$.subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + + it('returns false if sha is sha384 and generated hash does not equal atHash param', (done) => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const atHash = 'bad'; + + const result$ = tokenValidationService.validateIdTokenAtHash( + accessToken, + atHash, + '384', + { configId: 'configId1' } + ); + + result$.subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + + it('returns false if sha is sha512 and generated hash does not equal atHash param', (done) => { + const accessToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + const atHash = 'bad'; + + const result$ = tokenValidationService.validateIdTokenAtHash( + accessToken, + atHash, + '512', + { configId: 'configId1' } + ); + + result$.subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + }); + + describe('validateStateFromHashCallback', () => { + it('returns true when state and localstate match', () => { + const result = tokenValidationService.validateStateFromHashCallback( + 'sssd', + 'sssd', + { configId: 'configId1' } + ); + + expect(result).toEqual(true); + }); + + it('returns false when state and local state do not match', () => { + const result = tokenValidationService.validateStateFromHashCallback( + 'sssd', + 'bad', + { configId: 'configId1' } + ); + + expect(result).toEqual(false); + }); + }); + + describe('validateIdTokenExpNotExpired', () => { + it('returns false when getTokenExpirationDate returns null', () => { + spyOn(tokenHelperService, 'getTokenExpirationDate').and.returnValue( + null as unknown as Date + ); + const notExpired = tokenValidationService.validateIdTokenExpNotExpired( + 'idToken', + { configId: 'configId1' }, + 0 + ); + + expect(notExpired).toEqual(false); + }); + + it('returns false if token is not expired', () => { + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + + const notExpired = tokenValidationService.validateIdTokenExpNotExpired( + idToken, + { configId: 'configId1' }, + 0 + ); + + expect(notExpired).toEqual(false); + }); + }); + + describe('validateAccessTokenNotExpired', () => { + const testCases = [ + { + // Mon Jan 19 1970 10:26:50 GMT+0100, + date: new Date(1589210086), + offsetSeconds: 0, + expectedResult: false, + }, + { + // Sun Nov 01 2550 00:00:00 GMT+0100 + date: new Date(2550, 10), + offsetSeconds: 0, + expectedResult: true, + }, + { + date: null, + offsetSeconds: 300, + expectedResult: true, + }, + ]; + + testCases.forEach(({ date, offsetSeconds, expectedResult }) => { + it(`returns ${expectedResult} if ${date} is given with an offset of ${offsetSeconds}`, () => { + const notExpired = tokenValidationService.validateAccessTokenNotExpired( + date as Date, + { configId: 'configId1' }, + offsetSeconds + ); + + expect(notExpired).toEqual(expectedResult); + }); + }); + }); + + describe('hasIdTokenExpired', () => { + it('returns true if token has expired', () => { + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE1ODkyMTAwODYsIm5iZiI6MTU4OTIwNjQ4NiwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly9kYW1pZW5ib2QuYjJjbG9naW4uY29tL2EwOTU4ZjQ1LTE5NWItNDAzNi05MjU5LWRlMmY3ZTU5NGRiNi92Mi4wLyIsInN1YiI6ImY4MzZmMzgwLTNjNjQtNDgwMi04ZGJjLTAxMTk4MWMwNjhmNSIsImF1ZCI6ImYxOTM0YTZlLTk1OGQtNDE5OC05ZjM2LTYxMjdjZmM0Y2RiMyIsIm5vbmNlIjoiMDA3YzQxNTNiNmEwNTE3YzBlNDk3NDc2ZmIyNDk5NDhlYzVjbE92UVEiLCJpYXQiOjE1ODkyMDY0ODYsImF1dGhfdGltZSI6MTU4OTIwNjQ4NiwibmFtZSI6ImRhbWllbmJvZCIsImVtYWlscyI6WyJkYW1pZW5AZGFtaWVuYm9kLm9ubWljcm9zb2Z0LmNvbSJdLCJ0ZnAiOiJCMkNfMV9iMmNwb2xpY3lkYW1pZW4iLCJhdF9oYXNoIjoiWmswZktKU19wWWhPcE04SUJhMTJmdyJ9.E5Z-0kOzNU7LBkeVHHMyNoER8TUapGzUUfXmW6gVu4v6QMM5fQ4sJ7KC8PHh8lBFYiCnaDiTtpn3QytUwjXEFnLDAX5qcZT1aPoEgL_OmZMC-8y-4GyHp35l7VFD4iNYM9fJmLE8SYHTVl7eWPlXSyz37Ip0ciiV0Fd6eoksD_aVc-hkIqngDfE4fR8ZKfv4yLTNN_SfknFfuJbZ56yN-zIBL4GkuHsbQCBYpjtWQ62v98p1jO7NhHKV5JP2ec_Ge6oYc_bKTrE6OIX38RJ2rIm7zU16mtdjnl_350Nw3ytHcTPnA1VpP_VLElCfe83jr5aDHc_UQRYaAcWlOgvmVg'; + + const result = tokenValidationService.hasIdTokenExpired(idToken, { + configId: 'configId1', + }); + + expect(result).toBe(true); + }); + + it('returns false if token has not expired using offset', () => { + // expires 2050-02-12T08:02:30.823Z + const tokenExpires = new Date('2050-02-12T08:02:30.823Z'); + const idToken = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2MTMxMTY5NTAsImV4cCI6MjUyODI2NTc1MCwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoianJvY2tldEBleGFtcGxlLmNvbSIsIkdpdmVuTmFtZSI6IkpvaG5ueSIsIlN1cm5hbWUiOiJSb2NrZXQiLCJFbWFpbCI6Impyb2NrZXRAZXhhbXBsZS5jb20iLCJSb2xlIjpbIk1hbmFnZXIiLCJQcm9qZWN0IEFkbWluaXN0cmF0b3IiXX0.GHxRo23NghUTTeZx6VIzTSf05JEeEn7z9YYyFLxWv6M'; + + spyOn(tokenHelperService, 'getTokenExpirationDate').and.returnValue( + tokenExpires + ); + + const result = tokenValidationService.hasIdTokenExpired(idToken, { + configId: 'configId1', + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/validation/token-validation.service.ts b/src/validation/token-validation.service.ts new file mode 100644 index 0000000..b07135f --- /dev/null +++ b/src/validation/token-validation.service.ts @@ -0,0 +1,608 @@ +import { inject, Injectable } from 'injection-js'; +import { base64url } from 'rfc4648'; +import { from, Observable, of } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; +import { OpenIdConfiguration } from '../config/openid-configuration'; +import { JwkExtractor } from '../extractors/jwk.extractor'; +import { LoggerService } from '../logging/logger.service'; +import { TokenHelperService } from '../utils/tokenHelper/token-helper.service'; +import { JwkWindowCryptoService } from './jwk-window-crypto.service'; +import { JwtWindowCryptoService } from './jwt-window-crypto.service'; +import { alg2kty, getImportAlg, getVerifyAlg } from './token-validation.helper'; + +// http://openid.net/specs/openid-connect-implicit-1_0.html + +// id_token +// id_token C1: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) +// MUST exactly match the value of the iss (issuer) Claim. +// +// id_token C2: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified +// by the iss (issuer) Claim as an audience.The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, +// or if it contains additional audiences not trusted by the Client. +// +// id_token C3: If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. +// +// id_token C4: If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. +// +// id_token C5: The Client MUST validate the signature of the ID Token according to JWS [JWS] using the algorithm specified in the +// alg Header Parameter of the JOSE Header.The Client MUST use the keys provided by the Issuer. +// +// id_token C6: The alg value SHOULD be RS256. Validation of tokens using other signing algorithms is described in the OpenID Connect +// Core 1.0 +// [OpenID.Core] specification. +// +// id_token C7: The current time MUST be before the time represented by the exp Claim (possibly allowing for some small leeway to account +// for clock skew). +// +// id_token C8: The iat Claim can be used to reject tokens that were issued too far away from the current time, +// limiting the amount of time that nonces need to be stored to prevent attacks.The acceptable range is Client specific. +// +// id_token C9: The value of the nonce Claim MUST be checked to verify that it is the same value as the one that was sent +// in the Authentication Request.The Client SHOULD check the nonce value for replay attacks.The precise method for detecting replay attacks +// is Client specific. +// +// id_token C10: If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate. +// The meaning and processing of acr Claim Values is out of scope for this document. +// +// id_token C11: When a max_age request is made, the Client SHOULD check the auth_time Claim value and request re- authentication +// if it determines too much time has elapsed since the last End- User authentication. + +// Access Token Validation +// access_token C1: Hash the octets of the ASCII representation of the access_token with the hash algorithm specified in JWA[JWA] +// for the alg Header Parameter of the ID Token's JOSE Header. For instance, if the alg is RS256, the hash algorithm used is SHA-256. +// access_token C2: Take the left- most half of the hash and base64url- encode it. +// access_token C3: The value of at_hash in the ID Token MUST match the value produced in the previous step if at_hash is present +// in the ID Token. + +@Injectable() +export class TokenValidationService { + static refreshTokenNoncePlaceholder = '--RefreshToken--'; + + keyAlgorithms: string[] = [ + 'HS256', + 'HS384', + 'HS512', + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'PS256', + 'PS384', + 'PS512', + ]; + + private readonly tokenHelperService = inject(TokenHelperService); + + private readonly loggerService = inject(LoggerService); + + private readonly jwkExtractor = inject(JwkExtractor); + + private readonly jwkWindowCryptoService = inject(JwkWindowCryptoService); + + private readonly jwtWindowCryptoService = inject(JwtWindowCryptoService); + + // id_token C7: The current time MUST be before the time represented by the exp Claim + // (possibly allowing for some small leeway to account for clock skew). + hasIdTokenExpired( + token: string, + configuration: OpenIdConfiguration, + offsetSeconds?: number + ): boolean { + const decoded = this.tokenHelperService.getPayloadFromToken( + token, + false, + configuration + ); + + return !this.validateIdTokenExpNotExpired( + decoded, + configuration, + offsetSeconds + ); + } + + // id_token C7: The current time MUST be before the time represented by the exp Claim + // (possibly allowing for some small leeway to account for clock skew). + validateIdTokenExpNotExpired( + decodedIdToken: string, + configuration: OpenIdConfiguration, + offsetSeconds?: number + ): boolean { + const tokenExpirationDate = + this.tokenHelperService.getTokenExpirationDate(decodedIdToken); + + offsetSeconds = offsetSeconds || 0; + + if (!tokenExpirationDate) { + return false; + } + + const tokenExpirationValue = tokenExpirationDate.valueOf(); + const nowWithOffset = this.calculateNowWithOffset(offsetSeconds); + const tokenNotExpired = tokenExpirationValue > nowWithOffset; + + this.loggerService.logDebug( + configuration, + `Has idToken expired: ${!tokenNotExpired} --> expires in ${this.millisToMinutesAndSeconds( + tokenExpirationValue - nowWithOffset + )} , ${new Date(tokenExpirationValue).toLocaleTimeString()} > ${new Date( + nowWithOffset + ).toLocaleTimeString()}` + ); + + return tokenNotExpired; + } + + validateAccessTokenNotExpired( + accessTokenExpiresAt: Date, + configuration: OpenIdConfiguration, + offsetSeconds?: number + ): boolean { + // value is optional, so if it does not exist, then it has not expired + if (!accessTokenExpiresAt) { + return true; + } + + offsetSeconds = offsetSeconds || 0; + const accessTokenExpirationValue = accessTokenExpiresAt.valueOf(); + const nowWithOffset = this.calculateNowWithOffset(offsetSeconds); + const tokenNotExpired = accessTokenExpirationValue > nowWithOffset; + + this.loggerService.logDebug( + configuration, + `Has accessToken expired: ${!tokenNotExpired} --> expires in ${this.millisToMinutesAndSeconds( + accessTokenExpirationValue - nowWithOffset + )} , ${new Date( + accessTokenExpirationValue + ).toLocaleTimeString()} > ${new Date(nowWithOffset).toLocaleTimeString()}` + ); + + return tokenNotExpired; + } + + // iss + // REQUIRED. Issuer Identifier for the Issuer of the response.The iss value is a case-sensitive URL using the + // https scheme that contains scheme, host, + // and optionally, port number and path components and no query or fragment components. + // + // sub + // REQUIRED. Subject Identifier.Locally unique and never reassigned identifier within the Issuer for the End- User, + // which is intended to be consumed by the Client, e.g., 24400320 or AItOawmwtWwcT0k51BayewNvutrJUqsvl6qs7A4. + // It MUST NOT exceed 255 ASCII characters in length.The sub value is a case-sensitive string. + // + // aud + // REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an + // audience value. + // It MAY also contain identifiers for other audiences.In the general case, the aud value is an array of case-sensitive strings. + // In the common special case when there is one audience, the aud value MAY be a single case-sensitive string. + // + // exp + // REQUIRED. Expiration time on or after which the ID Token MUST NOT be accepted for processing. + // The processing of this parameter requires that the current date/ time MUST be before the expiration date/ time listed in the value. + // Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. + // Its value is a JSON [RFC7159] number representing the number of seconds from 1970- 01 - 01T00: 00:00Z as measured in UTC until + // the date/ time. + // See RFC 3339 [RFC3339] for details regarding date/ times in general and UTC in particular. + // + // iat + // REQUIRED. Time at which the JWT was issued. Its value is a JSON number representing the number of seconds from + // 1970- 01 - 01T00: 00: 00Z as measured + // in UTC until the date/ time. + validateRequiredIdToken( + dataIdToken: any, + configuration: OpenIdConfiguration + ): boolean { + let validated = true; + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'iss')) { + validated = false; + this.loggerService.logWarning( + configuration, + 'iss is missing, this is required in the id_token' + ); + } + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'sub')) { + validated = false; + this.loggerService.logWarning( + configuration, + 'sub is missing, this is required in the id_token' + ); + } + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'aud')) { + validated = false; + this.loggerService.logWarning( + configuration, + 'aud is missing, this is required in the id_token' + ); + } + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'exp')) { + validated = false; + this.loggerService.logWarning( + configuration, + 'exp is missing, this is required in the id_token' + ); + } + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'iat')) { + validated = false; + this.loggerService.logWarning( + configuration, + 'iat is missing, this is required in the id_token' + ); + } + + return validated; + } + + // id_token C8: The iat Claim can be used to reject tokens that were issued too far away from the current time, + // limiting the amount of time that nonces need to be stored to prevent attacks.The acceptable range is Client specific. + validateIdTokenIatMaxOffset( + dataIdToken: any, + maxOffsetAllowedInSeconds: number, + disableIatOffsetValidation: boolean, + configuration: OpenIdConfiguration + ): boolean { + if (disableIatOffsetValidation) { + return true; + } + + if (!Object.prototype.hasOwnProperty.call(dataIdToken, 'iat')) { + return false; + } + + const dateTimeIatIdToken = new Date(0); // The 0 here is the key, which sets the date to the epoch + + dateTimeIatIdToken.setUTCSeconds(dataIdToken.iat); + maxOffsetAllowedInSeconds = maxOffsetAllowedInSeconds || 0; + + const nowInUtc = new Date(new Date().toUTCString()); + const diff = nowInUtc.valueOf() - dateTimeIatIdToken.valueOf(); + const maxOffsetAllowedInMilliseconds = maxOffsetAllowedInSeconds * 1000; + + this.loggerService.logDebug( + configuration, + `validate id token iat max offset ${diff} < ${maxOffsetAllowedInMilliseconds}` + ); + + if (diff > 0) { + return diff < maxOffsetAllowedInMilliseconds; + } + + return -diff < maxOffsetAllowedInMilliseconds; + } + + // id_token C9: The value of the nonce Claim MUST be checked to verify that it is the same value as the one + // that was sent in the Authentication Request.The Client SHOULD check the nonce value for replay attacks. + // The precise method for detecting replay attacks is Client specific. + + // However the nonce claim SHOULD not be present for the refresh_token grant type + // https://bitbucket.org/openid/connect/issues/1025/ambiguity-with-how-nonce-is-handled-on + // The current spec is ambiguous and KeyCloak does send it. + validateIdTokenNonce( + dataIdToken: any, + localNonce: any, + ignoreNonceAfterRefresh: boolean, + configuration: OpenIdConfiguration + ): boolean { + const isFromRefreshToken = + (dataIdToken.nonce === undefined || ignoreNonceAfterRefresh) && + localNonce === TokenValidationService.refreshTokenNoncePlaceholder; + + if (!isFromRefreshToken && dataIdToken.nonce !== localNonce) { + this.loggerService.logDebug( + configuration, + 'Validate_id_token_nonce failed, dataIdToken.nonce: ' + + dataIdToken.nonce + + ' local_nonce:' + + localNonce + ); + + return false; + } + + return true; + } + + // id_token C1: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + validateIdTokenIss( + dataIdToken: any, + authWellKnownEndpointsIssuer: any, + configuration: OpenIdConfiguration + ): boolean { + if ( + (dataIdToken.iss as string) !== (authWellKnownEndpointsIssuer as string) + ) { + this.loggerService.logDebug( + configuration, + 'Validate_id_token_iss failed, dataIdToken.iss: ' + + dataIdToken.iss + + ' authWellKnownEndpoints issuer:' + + authWellKnownEndpointsIssuer + ); + + return false; + } + + return true; + } + + // id_token C2: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified + // by the iss (issuer) Claim as an audience. + // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences + // not trusted by the Client. + validateIdTokenAud( + dataIdToken: any, + aud: string | undefined, + configuration: OpenIdConfiguration + ): boolean { + if (Array.isArray(dataIdToken.aud)) { + const result = dataIdToken.aud.includes(aud); + + if (!result) { + this.loggerService.logDebug( + configuration, + 'Validate_id_token_aud array failed, dataIdToken.aud: ' + + dataIdToken.aud + + ' client_id:' + + aud + ); + + return false; + } + + return true; + } else if (dataIdToken.aud !== aud) { + this.loggerService.logDebug( + configuration, + 'Validate_id_token_aud failed, dataIdToken.aud: ' + + dataIdToken.aud + + ' client_id:' + + aud + ); + + return false; + } + + return true; + } + + validateIdTokenAzpExistsIfMoreThanOneAud(dataIdToken: any): boolean { + if (!dataIdToken) { + return false; + } + + return !( + Array.isArray(dataIdToken.aud) && + dataIdToken.aud.length > 1 && + !dataIdToken.azp + ); + } + + // If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value. + validateIdTokenAzpValid( + dataIdToken: any, + clientId: string | undefined + ): boolean { + if (!dataIdToken?.azp) { + return true; + } + + return dataIdToken.azp === clientId; + } + + validateStateFromHashCallback( + state: any, + localState: any, + configuration: OpenIdConfiguration + ): boolean { + if ((state as string) !== (localState as string)) { + this.loggerService.logDebug( + configuration, + 'ValidateStateFromHashCallback failed, state: ' + + state + + ' local_state:' + + localState + ); + + return false; + } + + return true; + } + + // id_token C5: The Client MUST validate the signature of the ID Token according to JWS [JWS] using the algorithm specified in the alg + // Header Parameter of the JOSE Header.The Client MUST use the keys provided by the Issuer. + // id_token C6: The alg value SHOULD be RS256. Validation of tokens using other signing algorithms is described in the + // OpenID Connect Core 1.0 [OpenID.Core] specification. + validateSignatureIdToken( + idToken: string, + jwtkeys: any, + configuration: OpenIdConfiguration + ): Observable { + if (!idToken) { + return of(true); + } + + if (!jwtkeys || !jwtkeys.keys) { + return of(false); + } + + const headerData = this.tokenHelperService.getHeaderFromToken( + idToken, + false, + configuration + ); + + if ( + Object.keys(headerData).length === 0 && + headerData.constructor === Object + ) { + this.loggerService.logWarning( + configuration, + 'id token has no header data' + ); + + return of(false); + } + + const kid: string = headerData.kid; + const alg: string = headerData.alg; + + const keys: JsonWebKey[] = jwtkeys.keys; + let foundKeys: JsonWebKey[]; + let key: JsonWebKey; + + if (!this.keyAlgorithms.includes(alg)) { + this.loggerService.logWarning(configuration, 'alg not supported', alg); + + return of(false); + } + + const kty = alg2kty(alg); + const use = 'sig'; + + try { + foundKeys = kid + ? this.jwkExtractor.extractJwk(keys, { kid, kty, use }, false) + : this.jwkExtractor.extractJwk(keys, { kty, use }, false); + + if (foundKeys.length === 0) { + foundKeys = kid + ? this.jwkExtractor.extractJwk(keys, { kid, kty }) + : this.jwkExtractor.extractJwk(keys, { kty }); + } + + key = foundKeys[0]; + } catch (e: any) { + this.loggerService.logError(configuration, e); + + return of(false); + } + + const algorithm = getImportAlg(alg); + + const signingInput = this.tokenHelperService.getSigningInputFromToken( + idToken, + true, + configuration + ); + const rawSignature = this.tokenHelperService.getSignatureFromToken( + idToken, + true, + configuration + ); + + return from( + this.jwkWindowCryptoService.importVerificationKey(key, algorithm) + ).pipe( + mergeMap((cryptoKey: CryptoKey) => { + const signature: Uint8Array = base64url.parse(rawSignature, { + loose: true, + }); + + const verifyAlgorithm = getVerifyAlg(alg); + + return from( + this.jwkWindowCryptoService.verifyKey( + verifyAlgorithm, + cryptoKey, + signature, + signingInput + ) + ); + }), + tap((isValid: boolean) => { + if (!isValid) { + this.loggerService.logWarning( + configuration, + 'incorrect Signature, validation failed for id_token' + ); + } + }) + ); + } + + // Accepts ID Token without 'kid' claim in JOSE header if only one JWK supplied in 'jwks_url' + //// private validate_no_kid_in_header_only_one_allowed_in_jwtkeys(header_data: any, jwtkeys: any): boolean { + //// this.oidcSecurityCommon.logDebug('amount of jwtkeys.keys: ' + jwtkeys.keys.length); + //// if (!header_data.hasOwnProperty('kid')) { + //// // no kid defined in Jose header + //// if (jwtkeys.keys.length != 1) { + //// this.oidcSecurityCommon.logDebug('jwtkeys.keys.length != 1 and no kid in header'); + //// return false; + //// } + //// } + + //// return true; + //// } + + // Access Token Validation + // access_token C1: Hash the octets of the ASCII representation of the access_token with the hash algorithm specified in JWA[JWA] + // for the alg Header Parameter of the ID Token's JOSE Header. For instance, if the alg is RS256, the hash algorithm used is SHA-256. + // access_token C2: Take the left- most half of the hash and base64url- encode it. + // access_token C3: The value of at_hash in the ID Token MUST match the value produced in the previous step if at_hash + // is present in the ID Token. + validateIdTokenAtHash( + accessToken: string, + atHash: string, + idTokenAlg: string, + configuration: OpenIdConfiguration + ): Observable { + this.loggerService.logDebug( + configuration, + 'at_hash from the server:' + atHash + ); + + // 'sha256' 'sha384' 'sha512' + let sha = 'SHA-256'; + + if (idTokenAlg.includes('384')) { + sha = 'SHA-384'; + } else if (idTokenAlg.includes('512')) { + sha = 'SHA-512'; + } + + return this.jwtWindowCryptoService + .generateAtHash('' + accessToken, sha) + .pipe( + mergeMap((hash: string) => { + this.loggerService.logDebug( + configuration, + 'at_hash client validation not decoded:' + hash + ); + if (hash === atHash) { + return of(true); // isValid; + } else { + return this.jwtWindowCryptoService + .generateAtHash('' + decodeURIComponent(accessToken), sha) + .pipe( + map((newHash: string) => { + this.loggerService.logDebug( + configuration, + '-gen access--' + hash + ); + + return newHash === atHash; + }) + ); + } + }) + ); + } + + private millisToMinutesAndSeconds(millis: number): string { + const minutes = Math.floor(millis / 60000); + const seconds = ((millis % 60000) / 1000).toFixed(0); + + return minutes + ':' + (+seconds < 10 ? '0' : '') + seconds; + } + + private calculateNowWithOffset(offsetSeconds: number): number { + return new Date(new Date().toUTCString()).valueOf() + offsetSeconds * 1000; + } +} diff --git a/src/validation/validation-result.ts b/src/validation/validation-result.ts new file mode 100644 index 0000000..745ed6c --- /dev/null +++ b/src/validation/validation-result.ts @@ -0,0 +1,18 @@ +export enum ValidationResult { + NotSet = 'NotSet', + StatesDoNotMatch = 'StatesDoNotMatch', + SignatureFailed = 'SignatureFailed', + IncorrectNonce = 'IncorrectNonce', + RequiredPropertyMissing = 'RequiredPropertyMissing', + MaxOffsetExpired = 'MaxOffsetExpired', + IssDoesNotMatchIssuer = 'IssDoesNotMatchIssuer', + NoAuthWellKnownEndPoints = 'NoAuthWellKnownEndPoints', + IncorrectAud = 'IncorrectAud', + IncorrectIdTokenClaimsAfterRefresh = 'IncorrectIdTokenClaimsAfterRefresh', + IncorrectAzp = 'IncorrectAzp', + TokenExpired = 'TokenExpired', + IncorrectAtHash = 'IncorrectAtHash', + Ok = 'Ok', + LoginRequired = 'LoginRequired', + SecureTokenServerError = 'SecureTokenServerError', +} diff --git a/test/auto-mock.ts b/test/auto-mock.ts new file mode 100644 index 0000000..231ba4a --- /dev/null +++ b/test/auto-mock.ts @@ -0,0 +1,49 @@ +import { Provider } from 'injection-js'; + +export function mockClass(obj: new (...args: any[]) => T): any { + const keys = Object.getOwnPropertyNames(obj.prototype); + const allMethods = keys.filter((key) => { + try { + return typeof obj.prototype[key] === 'function'; + } catch (error) { + return false; + } + }); + const allProperties = keys.filter((x) => !allMethods.includes(x)); + + const mockedClass = class T {}; + + allMethods.forEach( + (method: string) => + ((mockedClass.prototype as any)[method] = (): void => { + return; + }) + ); + + allProperties.forEach((method) => { + Object.defineProperty(mockedClass.prototype, method, { + get() { + return ''; + }, + configurable: true, + }); + }); + + return mockedClass; +} + +export function mockProvider(obj: new (...args: any[]) => T): Provider { + return { + provide: obj, + useClass: mockClass(obj), + }; +} + +export function mockAbstractProvider( + type: abstract new (...args: any[]) => T, + mockType: new (...args: any[]) => M +): Provider { + const mock = mockClass(mockType); + + return { provide: type, useClass: mock }; +} diff --git a/test/create-retriable-stream.helper.ts b/test/create-retriable-stream.helper.ts new file mode 100644 index 0000000..ec6fa36 --- /dev/null +++ b/test/create-retriable-stream.helper.ts @@ -0,0 +1,12 @@ +import { Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +// Create retriable observable stream to test retry / retryWhen. Credits to: +// https://stackoverflow.com/questions/51399819/how-to-create-a-mock-observable-to-test-http-rxjs-retry-retrywhen-in-angular +export const createRetriableStream = (...resp$: any): Observable => { + const fetchData: jasmine.Spy = jasmine.createSpy('fetchData'); + + fetchData.and.returnValues(...resp$); + + return of(null).pipe(switchMap((_) => fetchData())); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..79ead0d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "strict": true, + "noEmit": true, + "sourceMap": true, + "declaration": false, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "target": "ES2015", + "module": "ESNext", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "useDefineForClassFields": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "paths": { + "injection-js": [ + "./node_modules/injection-js/lib" + ] + } + }, + "include": ["src"] +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..6c61a1f --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": ["node"], + "lib": ["dom", "es2018"] + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..5473296 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine", "node"] + }, + "files": ["src/test.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +}