feat: init and fork some code from angular-auth-oidc-client
Some checks are pending
Build, Lint & Test Lib / Built, Lint and Test Library (push) Waiting to run
Build, Lint & Test Lib / Angular latest (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest Standalone & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular 16 & RxJs 6 (push) Blocked by required conditions
Build, Lint & Test Lib / Angular V16 (push) Blocked by required conditions
Docs / Build and Deploy Docs Job (push) Waiting to run
Docs / Close Pull Request Job (push) Waiting to run

This commit is contained in:
master 2025-01-18 01:05:00 +08:00
parent 276d9fbda8
commit 983254164b
201 changed files with 35689 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -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

39
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@ -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

View File

@ -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.

13
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@ -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 ...

7
.github/ISSUE_TEMPLATE/refactoring.md vendored Normal file
View File

@ -0,0 +1,7 @@
---
name: Code refactoring
about: Any code improvements which make the code better
title: '[Refactoring]: '
assignees: 'FabianGosebrink'
labels: ['refactoring']
---

BIN
.github/angular-auth-logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

265
.github/workflows/build.yml vendored Normal file
View File

@ -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

61
.github/workflows/deploy-docs.yml vendored Normal file
View File

@ -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'

53
.gitignore vendored Normal file
View File

@ -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

35
.vscode/settings.json vendored Normal file
View File

@ -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"
]
}

207
README.md Normal file
View File

@ -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)
<p align="center">
<a href="https://angular-auth-oidc-client.com/"><img src="https://raw.githubusercontent.com/damienbod/angular-auth-oidc-client/main/.github/angular-auth-logo.png" alt="" width="350" /></a>
</p>
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 <a href="http://openid.net/certification/#RPs">certified</a> by OpenID Foundation. (RP Implicit and Config RP)
<p align="center">
<a href="http://openid.net/certification/#RPs"><img src="https://damienbod.files.wordpress.com/2017/06/oid-l-certification-mark-l-rgb-150dpi-90mm.png" alt="" width="400" /></a>
</p>
## 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: '<your authority address here>',
redirectUrl: window.location.origin,
postLogoutRedirectUri: window.location.origin,
clientId: '<your 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)

25
biome.json Normal file
View File

@ -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"
]
}
}

50
karma.conf.js Normal file
View File

@ -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,
});
};

41
lefthook.yml Normal file
View File

@ -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

4
license-banner.txt Normal file
View File

@ -0,0 +1,4 @@
/**
* @license oidc-client-rx
* MIT license
*/

83
package.json Normal file
View File

@ -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"
]
}

2614
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

21
rslib.config.ts Normal file
View File

@ -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',
},
});

View File

@ -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();
}));
});
});

65
src/api/data.service.ts Normal file
View File

@ -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<T>(
url: string,
config: OpenIdConfiguration,
token?: string
): Observable<T> {
const headers = this.prepareHeaders(token);
const params = this.prepareParams(config);
return this.httpClient.get<T>(url, {
headers,
params,
});
}
post<T>(
url: string | null,
body: unknown,
config: OpenIdConfiguration,
headersParams?: HttpHeaders
): Observable<T> {
const headers = headersParams || this.prepareHeaders();
const params = this.prepareParams(config);
return this.httpClient.post<T>(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;
}
}

View File

@ -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<T>(url: string, params?: { [key: string]: unknown }): Observable<T> {
return this.http.get<T>(url, params);
}
post<T>(
url: string,
body: unknown,
params?: { [key: string]: unknown }
): Observable<T> {
return this.http.post<T>(url, body, params);
}
}

17
src/auth-config.spec.ts Normal file
View File

@ -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!'
);
});
});
});

25
src/auth-config.ts Normal file
View File

@ -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<PassedInitialConfig>(
'PASSED_CONFIG'
);

12
src/auth-options.ts Normal file
View File

@ -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';
}

View File

@ -0,0 +1,9 @@
export interface AuthenticatedResult {
isAuthenticated: boolean;
allConfigsAuthenticated: ConfigAuthenticatedResult[];
}
export interface ConfigAuthenticatedResult {
configId: string;
isAuthenticated: boolean;
}

View File

@ -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
);
});
});
});

View File

@ -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<AuthenticatedResult>(DEFAULT_AUTHRESULT);
get authenticated$(): Observable<AuthenticatedResult> {
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<AuthStateResult>(
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<boolean>(
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<boolean>(
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 };
}
}

View File

@ -0,0 +1,7 @@
import { ValidationResult } from '../validation/validation-result';
export interface AuthStateResult {
isAuthenticated: boolean;
validationResult: ValidationResult;
isRenewProcess: boolean;
}

View File

@ -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'
);
},
});
}));
});
});

View File

@ -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<LoginResponse> {
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<LoginResponse[]> {
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<LoginResponse> {
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<LoginResponse> {
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<LoginResponse[]> {
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]);
}
}

61
src/auth.module.spec.ts Normal file
View File

@ -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);
});
});
});

25
src/auth.module.ts Normal file
View File

@ -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<AuthModule> {
return {
ngModule: AuthModule,
providers: [..._provideAuth(passedConfig)],
};
}
}

View File

@ -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<boolean>;
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<boolean>;
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<boolean>;
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<boolean>;
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<boolean>;
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<boolean>;
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<boolean>;
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<boolean>;
canLoad$.subscribe(() => {
expect(saveRedirectRouteSpy).not.toHaveBeenCalled();
expect(loginSpy).not.toHaveBeenCalled();
expect(
checkSavedRedirectRouteAndNavigateSpy
).toHaveBeenCalledOnceWith({ configId: 'configId1' });
});
}));
});
});
});

View File

@ -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<boolean> {
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<boolean | UrlTree> {
return checkAuth(
state.url,
this.configurationService,
this.checkAuthService,
this.autoLoginService,
this.loginService
);
}
canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
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<boolean> {
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;
})
);
})
);
}

View File

@ -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();
});
}));
});
});
});

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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;
})
);
}

View File

@ -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',
});
});
});
});

View File

@ -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);
}
}

View File

@ -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();
});
}));
});
});

View File

@ -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<void>();
get stsCallback$(): Observable<void> {
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<CallbackContext> {
let callback$: Observable<CallbackContext> = 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()));
}
}

View File

@ -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');
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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));
})
);
}
}

View File

@ -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');
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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));
})
);
}
}

View File

@ -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();
}));
});
});

View File

@ -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<unknown> {
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);
};
});
}
}

View File

@ -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();
});
});
});

View File

@ -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<boolean | CallbackContext | null>;
} = {};
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<boolean | CallbackContext | null> {
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<boolean | CallbackContext | null> {
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;
}
}

View File

@ -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();
}));
});
});

View File

@ -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<CallbackContext> {
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()
)
);
}
}

View File

@ -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();
});
}));
});
});

View File

@ -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<LoginResponse> {
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<LoginResponse> {
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<boolean | CallbackContext | null> {
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
);
})
);
}
}

View File

@ -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));
});
}));
});
});

View File

@ -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<AuthWellKnownEndpoints> {
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<any> {
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));
}
}

View File

@ -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;
}

View File

@ -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
);
},
});
}));
});
});

View File

@ -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<AuthWellKnownEndpoints> {
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));
})
);
}
}

View File

@ -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,
});
});
});
});

View File

@ -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<string, OpenIdConfiguration> = {};
hasManyConfigs(): boolean {
return Object.keys(this.configsInternal).length > 1;
}
getAllConfigurations(): OpenIdConfiguration[] {
return Object.values(this.configsInternal);
}
getOpenIDConfiguration(
configId?: string
): Observable<OpenIdConfiguration | null> {
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<OpenIdConfiguration[]> {
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<OpenIdConfiguration[]> {
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<OpenIdConfiguration | null> {
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<OpenIdConfiguration>(
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;
}
}
}

View File

@ -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,
};

View File

@ -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');
});
}));
});
});
});

View File

@ -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<OpenIdConfiguration[]>;
}
export class StsConfigStaticLoader implements StsConfigLoader {
constructor(
private readonly passedConfigs: OpenIdConfiguration | OpenIdConfiguration[]
) {}
loadConfigs(): Observable<OpenIdConfiguration[]> {
if (Array.isArray(this.passedConfigs)) {
return of(this.passedConfigs);
}
return of([this.passedConfigs]);
}
}
export class StsConfigHttpLoader implements StsConfigLoader {
constructor(
private readonly configs$:
| Observable<OpenIdConfiguration>
| Observable<OpenIdConfiguration>[]
| Observable<OpenIdConfiguration[]>
) {}
loadConfigs(): Observable<OpenIdConfiguration[]> {
if (Array.isArray(this.configs$)) {
return forkJoin(this.configs$);
}
const singleConfigOrArray = this.configs$ as Observable<unknown>;
return singleConfigOrArray.pipe(
map((value: unknown) => {
if (Array.isArray(value)) {
return value as OpenIdConfiguration[];
}
return [value] as OpenIdConfiguration[];
})
);
}
}

View File

@ -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
}

View File

@ -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);
});
});
});

View File

@ -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), []);
}
}

View File

@ -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';

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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];

View File

@ -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;
};

3
src/dom/index.ts Normal file
View File

@ -0,0 +1,3 @@
import { InjectionToken } from "injection-js";
export const DOCUMENT = new InjectionToken<Document>('document');

View File

@ -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);
});
});
});

View File

@ -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',
};

View File

@ -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;
}

View File

@ -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);
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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<CallbackContext> {
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<unknown>,
config: OpenIdConfiguration
): Observable<unknown> {
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);
})
);
}
}

View File

@ -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();
});
});
});

View File

@ -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));

View File

@ -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);
});
});
});

View File

@ -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<CallbackContext> {
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);
}
}

View File

@ -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);
});
}));
});
});

View File

@ -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<CallbackContext> {
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);
}
}

View File

@ -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();
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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));
}
}
}

View File

@ -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);
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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<AuthResult>(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<unknown>,
config: OpenIdConfiguration
): Observable<unknown> {
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);
})
);
}
}

View File

@ -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,
});
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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,
});
}
}

View File

@ -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'
);
},
});
}));
});
});

View File

@ -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<CallbackContext> {
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,
});
}
}

View File

@ -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',
});
});
});
});

View File

@ -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);
}
}

View File

@ -0,0 +1,6 @@
export interface SilentRenewRunning {
state: SilentRenewRunningState;
dateOfLaunchedProcessUtc: string;
}
export type SilentRenewRunningState = 'running' | 'not-running';

View File

@ -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();
});
}));
});
});

182
src/flows/flows.service.ts Normal file
View File

@ -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<CallbackContext> {
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<CallbackContext> {
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<CallbackContext> {
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<CallbackContext> {
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
)
)
);
}
}

View File

@ -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);
});
}
});

Some files were not shown because too many files have changed in this diff Show More