├── .editorconfig ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── issue-template.md └── workflows │ └── ci-workflow.yml ├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── angular.json ├── e2e ├── basic-app-structure.spec.ts └── happy-path-flow.spec.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── screenshot-001.png ├── src ├── .browserslistrc ├── app │ ├── app-menu.component.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── core │ │ ├── auth-app-initializer.factory.ts │ │ ├── auth-config.ts │ │ ├── auth-guard-with-forced-login.service.ts │ │ ├── auth-guard.service.ts │ │ ├── auth-module-config.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.service.ts │ │ └── core.module.ts │ ├── fallback.component.ts │ ├── feature-basics │ │ ├── admin1.component.ts │ │ ├── basics.module.ts │ │ ├── home.component.ts │ │ └── public.component.ts │ ├── feature-extras │ │ ├── admin2.component.ts │ │ └── extras.module.ts │ ├── shared │ │ ├── api.service.ts │ │ └── shared.module.ts │ └── should-login.component.ts ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── polyfills.ts ├── silent-refresh.html ├── styles.css ├── test.ts ├── tsconfig.app.json └── tsconfig.spec.json ├── ssl ├── certificate.cnf ├── localhost.crt └── localhost.key └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json", 14 | "e2e/tsconfig.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "extends": [ 19 | "plugin:@angular-eslint/recommended", 20 | "plugin:@angular-eslint/template/process-inline-templates" 21 | ], 22 | "rules": { 23 | "@angular-eslint/prefer-standalone": "off", 24 | "@angular-eslint/directive-selector": [ 25 | "error", 26 | { 27 | "type": "attribute", 28 | "prefix": "app", 29 | "style": "camelCase" 30 | } 31 | ], 32 | "@angular-eslint/component-selector": [ 33 | "error", 34 | { 35 | "type": "element", 36 | "prefix": "app", 37 | "style": "kebab-case" 38 | } 39 | ] 40 | } 41 | }, 42 | { 43 | "files": [ 44 | "*.html" 45 | ], 46 | "extends": [ 47 | "plugin:@angular-eslint/template/recommended" 48 | ], 49 | "rules": {} 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: General purpose issue template with guidelines 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Read below text before submitting an issue, please. 11 | Delete this text and replace it with your own text afterwards. 12 | 13 | Thanks for taking a moment to open an issue! 14 | Here are some guidelines for issues: 15 | 16 | - **Bugs need steps to reproduce!** Please add a minimal but complete set of steps to reproduce the issue. This is needed to reliably help. 17 | - **Questions might get limited attention.** This sample is a side-project of _one_ person. I might not have time to help anyone, esp. with app-specific questions (ask a colleague, consultant, or Stack Overflow, please), or library-specific questions (open an issue on the library's issue list please). 18 | - **Scope is intentionally small.** There is intentionally no documentation (the code _is_ the documentation), and only a small number of features. This is just a _sample_ repository. Build your own use cases from it. 19 | 20 | Opening an issue is also a good step before contributing a PR, so we can have a short discussion about any fix or addition. 21 | 22 | Thanks again for taking an interest in this sample! 23 | -------------------------------------------------------------------------------- /.github/workflows/ci-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Lint-Build-Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | # Based on: https://docs.github.com/en/actions/configuring-and-managing-workflows/caching-dependencies-to-speed-up-workflows#example-using-the-cache-action 20 | - name: Cache node modules 21 | uses: actions/cache@v3 22 | env: 23 | cache-name: cache-node-modules 24 | with: 25 | # npm cache files are stored in `~/.npm` on Linux/macOS 26 | path: ~/.npm 27 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.os }}-build-${{ env.cache-name }}- 30 | ${{ runner.os }}-build- 31 | ${{ runner.os }}- 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Lint 37 | run: npm run lint 38 | 39 | - name: Playwright - Install Browsers 40 | run: npx playwright install --with-deps 41 | 42 | - name: Playwright - Run Tests 43 | run: npx playwright test 44 | 45 | - name: Playwright - Upload Report 46 | uses: actions/upload-artifact@v3 47 | if: always() 48 | with: 49 | name: playwright-report 50 | path: playwright-report/ 51 | retention-days: 30 52 | 53 | - name: Failure notifications 54 | if: ${{ failure() }} 55 | uses: rtCamp/action-slack-notify@v2.1.0 56 | env: 57 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 58 | SLACK_USERNAME: ${{github.repository}} 59 | SLACK_FOOTER: null 60 | SLACK_COLOR: '#d0380b' 61 | SLACK_TITLE: 'CI Failure' 62 | SLACK_MESSAGE: ':no_entry: https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}' 63 | MSG_MINIMAL: true 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.angular/cache 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | /test-results/ 42 | /playwright-report/ 43 | /blob-report/ 44 | /playwright/.cache/ 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/dist": true, 4 | "**/node_modules": true, 5 | "**/.git": true, 6 | "**/.svn": true, 7 | "**/.hg": true, 8 | "**/CVS": true, 9 | "**/.DS_Store": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thanks for taking the time to help out with this example. 4 | Here's some rough, basic guidance for contributing. 5 | 6 | ## General guidelines 7 | 8 | Contributions are much welcomed! 9 | This is only a small project (intentionally!) and as such there are only _rough_ guidelines: 10 | 11 | - **Scope is intentionally small**: please be aware that we might not include contributions, even if they are good, if they increase scope of this repository too much. 12 | - **Open an issue first**: if you want to fix or add something, consider opening up an issue first, so we can coodinate efforts, and prevent unneeded work (see previous point). 13 | 14 | In short: propose your idea or bug to fix, and then we'll get it rolling. 15 | 16 | ## Where to begin? 17 | 18 | There's little to no documentation for this project. 19 | This is _intentional_ though: the source code _is_ the documentation here. 20 | You're meant to go through all the files (at least the `/src` files) to learn what it's about. 21 | So that's also what's likely needed if you want to contribute something. 22 | 23 | Beyond that (reading the code), there's not much to it. 24 | Typical flow for doing a fix or addition would be: 25 | 26 | 1. Read through the code 27 | 2. Open an issue to propose your fix or addition 28 | 3. Prepare the change 29 | 1. Fork the repository 30 | 2. Clone it on your computer 31 | 3. Create a branch (e.g. `100-fix-bug-with-safari-cookies`) 32 | 4. YOUR CHANGES HERE 33 | 5. Run `ng e2e` and `ng lint`, please 34 | 6. Manually check if things work (`ng serve`) 35 | 7. Push your branch to your fork 36 | 4. Open a PR (e.g. from your fork's home page) 37 | 38 | And we should be good to go! 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018, Jeroen Heijmans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example angular-oauth2-oidc with AuthGuard 2 | 3 | This repository shows a basic Angular CLI application with [the `angular-oauth2-oidc` library](https://github.com/manfredsteyer/angular-oauth2-oidc) and Angular AuthGuards. 4 | 5 | [![Lint-Build-Test](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/actions/workflows/ci-workflow.yml/badge.svg)](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/actions/workflows/ci-workflow.yml) 6 | 7 | ## ⚠ Third-party Cookies 8 | 9 | **TLDR 👉 See [my "SPA Necromancy" blogpost for all options and workarounds known to me](https://infi.nl/nieuws/spa-necromancy/).** 10 | 11 | Browser vendors are implementing increasingly strict rules around cookies. 12 | This is increasingly problematic for SPA's with their Identity Server on a third-party domain. 13 | Most notably **problems occur if the "silent refresh via an iframe" technique is used**. 14 | 15 | This repository uses that technique currently, [starting with a `silentRefresh()`](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/36316ee1971a8a8160033f55ba7eabe14f7d3add/src/app/core/auth.service.ts#L106-L109). 16 | This will fire up an iframe to load an IDS page with `noprompt`, hoping cookies get sent along to so the IDS can see if a user is logged in. 17 | 18 | [Safari will block cookies from being sent](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/), prompting a leading OAuth/OpenID community member to write "[SPAs are dead!?](https://leastprivilege.com/2020/03/31/spas-are-dead/)". 19 | In fact, if you fire up this sample repository on `localhost`, which talks to `demo.duendesoftware.com` (another domain!), and use it in Safari: you will notice that the silent refresh technique already fails! 20 | 21 | For reference, see [issue #40](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/issues/40), or [my blogpost that explains workarounds and solutions](https://infi.nl/nieuws/spa-necromancy/). 22 | 23 | ## Features 24 | 25 | ⚠ To see **the Implicit Flow** refer to [the `implicit-flow` branch](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/tree/implicit-flow) (which might be getting outdated, since Code Flow is now the recommended flow). 26 | 27 | This demonstrates: 28 | 29 | - Use of **the Code+PKCE Flow** (so no JWKS validation) 30 | - Async but mandatory bootstrapping (via an `APP_INITIALIZER`) before the rest of the app can run 31 | - Modules (core, shared, and two feature modules) 32 | - An auth guard that forces you to login when navigating to protected routes 33 | - An auth guard that just prevents you from navigating to protected routes 34 | - Asynchronous loading of login information (and thus async auth guards) 35 | - Using `localStorage` for storing tokens (use at your own risk!) 36 | - Loading IDS details from its discovery document 37 | - Trying refresh on app startup before potientially starting a login flow 38 | - OpenID's external logout features 39 | 40 | Most interesting features can be found in [the core module](./src/app/core). 41 | 42 | ## Implicit Flow 43 | 44 | If you need an example of the _Implicit Flow_ check out [the last commit with that flow](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/commit/3c95d8891b4c086d5cd109d05cdd66171ef4b960) or even earlier versions. 45 | For new applications Code+PKCE flow is recommended for JavaScript clients, and this example repository now demonstrates this as the main use case. 46 | 47 | ## Usage 48 | 49 | This repository has been scaffolded with the Angular 5 CLI, then later upgraded to newer versions of the Angular CLI. 50 | To use the repository: 51 | 52 | 1. Clone this repository 53 | 1. Run `npm ci` to get the exact locked dependencies 54 | 1. Run `npm run start` (or `start-with-ssl`) to get it running on [http://localhost:4200](http://localhost:4200) (or [https://localhost:4200](https://localhost:4200)) 55 | 56 | This connects to the [demo Duende IdentityServer instance](https://demo.duendesoftware.com/) also used in the library's examples. 57 | The **credentials** and ways of logging in are disclosed on the login page itself (as it's only a demo server). 58 | 59 | You could also connect to your own IdentityServer by changing `auth-config.ts`. 60 | Note that your server must whitelist both `http://localhost:4200/index.html` and `http://localhost:4200/silent-refresh.html` for this to work. 61 | 62 | You can run the end-to-end tests using: 63 | 64 | 1. Run `npx playwright install` to grab the Playwright browsers 65 | 2. Run `npm run test` to run the specs 66 | 67 | ## Differences between Identity Server options 68 | 69 | **This repository demonstrates features using https://demo.duendesoftware.com (Duende IdentityServer)**. 70 | There are various other server side solutions available, each with their own intricacies. 71 | This codebase does not keep track itself of the specifics for each other server side solution. 72 | Instead, we recommend you look for specific guidance for other solutions elsewhere. 73 | Here are some potential starting points you could consider: 74 | 75 | - IdenitityServer4 76 | - This sample itself uses IDS4 77 | - Auth0 78 | - [github.com/jeroenheijmans/sample-auth0-angular-oauth2-oidc](https://github.com/jeroenheijmans/sample-auth0-angular-oauth2-oidc): Angular 6 and Auth0 integration 79 | - Keycloak 80 | - No samples or tutorials yet 81 | - Okta 82 | - No samples or tutorials yet 83 | - Azure AD 84 | - [Community-provided steps listed in a (closed) GitHub issue](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/issues/119) 85 | - ... 86 | 87 | Feel free to open an issue and PR if you want to add additional pieces of guidance to this section. 88 | 89 | ## Example 90 | 91 | The application is supposed to look somewhat like this: 92 | 93 | ![Application Screenshot](screenshot-001.png) 94 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | This project is meant as a sample, that can also be used as a starting point for your own implementation. 4 | It is meant as _inspiration_ and we advise the reader to use their own judgement and security analysis when using the code herein. 5 | 6 | ## Main attack vectors 7 | 8 | The main types of problems that can occur with this repository: 9 | 10 | - **On developer machines**: if you fork and/or clone this repository, install dependencies, and run the sample, you might be vulnerable to problems with (dev) dependencies that are not up to date. Update locally if you want to be more sure of having no issues. 11 | - **For users at runtime**: if you use this repository as a starting point, then _you_ should take care to update dependencies to a point where it protects your users adequately. The ones in this repository might be slightly out of date. 12 | 13 | For either scenario, feel free to report a problem you feel should be fixed in the sample repo itself. 14 | 15 | ## Reporting an issue 16 | 17 | To report a (potential) security problem _with the sample_, please [open an issue on GitHub](https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/issues). 18 | Please note that we are not able to give individual projects or spin-offs from this sample. 19 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "sample-auth-guards": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular/build:application", 15 | "options": { 16 | "outputPath": { 17 | "base": "dist/sample-auth-guards" 18 | }, 19 | "index": "src/index.html", 20 | "polyfills": [ 21 | "src/polyfills.ts" 22 | ], 23 | "tsConfig": "src/tsconfig.app.json", 24 | "assets": [ 25 | "src/favicon.ico", 26 | "src/assets", 27 | "src/silent-refresh.html" 28 | ], 29 | "styles": [ 30 | "src/styles.css" 31 | ], 32 | "scripts": [], 33 | "extractLicenses": false, 34 | "sourceMap": true, 35 | "optimization": false, 36 | "namedChunks": true, 37 | "browser": "src/main.ts" 38 | }, 39 | "configurations": { 40 | "production": { 41 | "budgets": [ 42 | { 43 | "type": "initial", 44 | "maximumWarning": "500kb", 45 | "maximumError": "10mb" 46 | }, 47 | { 48 | "type": "anyComponentStyle", 49 | "maximumWarning": "2kb", 50 | "maximumError": "4kb" 51 | } 52 | ], 53 | "fileReplacements": [ 54 | { 55 | "replace": "src/environments/environment.ts", 56 | "with": "src/environments/environment.prod.ts" 57 | } 58 | ], 59 | "outputHashing": "all" 60 | }, 61 | "development": { 62 | "optimization": false, 63 | "extractLicenses": false, 64 | "sourceMap": true, 65 | "namedChunks": true 66 | } 67 | }, 68 | "defaultConfiguration": "production" 69 | }, 70 | "serve": { 71 | "builder": "@angular/build:dev-server", 72 | "options": { 73 | "ssl": true, 74 | "sslKey": "ssl/localhost.key", 75 | "sslCert": "ssl/localhost.crt", 76 | "buildTarget": "sample-auth-guards:build" 77 | }, 78 | "configurations": { 79 | "development": { 80 | "buildTarget": "sample-auth-guards:build:development" 81 | }, 82 | "production": { 83 | "buildTarget": "sample-auth-guards:build:production" 84 | } 85 | }, 86 | "defaultConfiguration": "development" 87 | }, 88 | "extract-i18n": { 89 | "builder": "@angular/build:extract-i18n", 90 | "options": { 91 | "buildTarget": "sample-auth-guards:build" 92 | } 93 | }, 94 | "lint": { 95 | "builder": "@angular-eslint/builder:lint", 96 | "options": { 97 | "lintFilePatterns": [ 98 | "src/**/*.ts", 99 | "src/**/*.html" 100 | ] 101 | } 102 | } 103 | } 104 | } 105 | }, 106 | "cli": { 107 | "analytics": false 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /e2e/basic-app-structure.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Basic app structure', () => { 4 | test('navigate around the sample app', async({ page }) => { 5 | await page.goto("/"); 6 | await expect(page.locator("h1")).toHaveText('Welcome'); 7 | await expect(page.locator('.container-fluid p.alert')).toContainText('🏠 HOME'); 8 | }) 9 | 10 | test.afterEach(async () => { 11 | // This was a trick from Protractor we can consider re-enabling later. It double 12 | // checks if there's no secret error messages lingering around. For now we'll 13 | // merge the Protractor-replacement to have at least something. 14 | // await assertNoUnexpectedBrowserErrorsOnConsole(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /e2e/happy-path-flow.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, type Page } from '@playwright/test'; 2 | 3 | test.describe('Happy Path Flow', () => { 4 | let page: Page; 5 | 6 | test.describe.configure({ mode: 'serial' }); 7 | 8 | test.beforeAll(async ({ browser }) => { 9 | page = await browser.newPage(); 10 | }); 11 | 12 | test('should start at home', async () => { 13 | await page.goto("/"); 14 | await expect(page.locator('h1')).toContainText('Welcome'); 15 | }); 16 | 17 | test('should be able to navigate to a public page', async () => { 18 | await page.locator(`nav a[href="/basics/public"]`).click() 19 | await expect(page.locator('h1')).toContainText('Welcome'); 20 | await expect(page.locator('.container-fluid p.alert')).toContainText('🌐 PUBLIC'); 21 | }); 22 | 23 | test('should see user is not logged in at the start', async () => { 24 | await expect(page.locator('#isAuthenticated')).toHaveText('false'); 25 | await expect(page.locator('#hasValidToken')).toHaveText('false'); 26 | await expect(page.locator('#isDoneLoading')).toHaveText('true'); 27 | await expect(page.locator('#canActivateProtectedRoutes')).toHaveText('false'); 28 | }); 29 | 30 | test('should be able to navigate to IDS4', async () => { 31 | await page.locator(".btn", { hasText: "login"}).click(); 32 | }); 33 | 34 | test('should be able to log in on IDS4', async () => { 35 | await page.locator('input#Input_Username').fill('bob'); 36 | await page.locator('input#Input_Password').fill('bob'); 37 | await page.locator('button[value=login]').click(); 38 | }); 39 | 40 | test('should have silently refreshed and show being logged in', async () => { 41 | await expect(page.locator('#email')).toHaveText('BobSmith@email.com'); 42 | }); 43 | 44 | test('should show expected debug booleans', async () => { 45 | await expect(page.locator('#isAuthenticated')).toHaveText('true'); 46 | await expect(page.locator('#hasValidToken')).toHaveText('true'); 47 | await expect(page.locator('#isDoneLoading')).toHaveText('true'); 48 | await expect(page.locator('#canActivateProtectedRoutes')).toHaveText('true'); 49 | }); 50 | 51 | test('should show expected identity claims', async () => { 52 | await expect(page.locator('#identityClaims')).toContainText('"iss": "https://demo.duendesoftware.com"'); 53 | await expect(page.locator('#identityClaims')).toContainText('"name": "Bob Smith"'); 54 | }); 55 | 56 | test('should be able to navigate to Admin-1 page', async () => { 57 | await page.locator(`nav a[href="/basics/admin1"]`).click() 58 | await expect(page.locator('h1')).toContainText('Welcome'); 59 | await expect(page.locator('.container-fluid p.alert')).toContainText('ADMIN'); 60 | await expect(page.locator('.container-fluid p.alert')).toContainText('API Success'); 61 | }); 62 | 63 | test('should be able to log out via IDS4', async () => { 64 | await page.locator('.btn-primary', { hasText: 'logout' }).click(); 65 | await expect(page.getByText('now logged out')).toBeVisible(); 66 | }); 67 | 68 | test('should be able to return to the app', async () => { 69 | await page.locator('.PostLogoutRedirectUri').click(); 70 | }); 71 | 72 | test('should see user is not logged in at the end', async () => { 73 | await expect(page.locator('#isAuthenticated')).toHaveText('false'); 74 | await expect(page.locator('#hasValidToken')).toHaveText('false'); 75 | await expect(page.locator('#isDoneLoading')).toHaveText('true'); 76 | await expect(page.locator('#canActivateProtectedRoutes')).toHaveText('false'); 77 | }); 78 | 79 | test.afterEach(async () => { 80 | // This was a trick from Protractor we can consider re-enabling later. It double 81 | // checks if there's no secret error messages lingering around. For now we'll 82 | // merge the Protractor-replacement to have at least something. 83 | // await assertNoUnexpectedBrowserErrorsOnConsole(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample-angular-oauth2-oidc-with-auth-guards", 3 | "version": "1.0.0", 4 | "homepage": "https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve --ssl=false", 8 | "start-with-ssl": "ng serve", 9 | "build": "ng build", 10 | "lint": "ng lint", 11 | "test": "npx playwright test", 12 | "e2e": "npx playwright test" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "19.1.2", 17 | "@angular/common": "19.1.2", 18 | "@angular/compiler": "19.1.2", 19 | "@angular/core": "19.1.2", 20 | "@angular/forms": "19.1.2", 21 | "@angular/platform-browser": "19.1.2", 22 | "@angular/platform-browser-dynamic": "19.1.2", 23 | "@angular/router": "19.1.2", 24 | "angular-oauth2-oidc": "19.0.0", 25 | "rxjs": "7.8.1", 26 | "tslib": "2.8.1", 27 | "zone.js": "0.15.0" 28 | }, 29 | "devDependencies": { 30 | "@angular-eslint/builder": "19.0.2", 31 | "@angular-eslint/eslint-plugin": "19.0.2", 32 | "@angular-eslint/eslint-plugin-template": "19.0.2", 33 | "@angular-eslint/schematics": "19.0.2", 34 | "@angular-eslint/template-parser": "19.0.2", 35 | "@angular/build": "19.1.3", 36 | "@angular/cli": "19.1.3", 37 | "@angular/compiler-cli": "19.1.2", 38 | "@playwright/test": "1.49.1", 39 | "@types/node": "22.10.7", 40 | "@typescript-eslint/eslint-plugin": "8.21.0", 41 | "@typescript-eslint/parser": "8.21.0", 42 | "codelyzer": "6.0.2", 43 | "typescript": "5.7.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: './e2e', 5 | fullyParallel: true, 6 | forbidOnly: !!process.env.CI, /* Fail the build on CI if you accidentally left test.only in the source code. */ 7 | retries: process.env.CI ? 2 : 0, /* Retry on CI only */ 8 | workers: process.env.CI ? 1 : undefined, /* Opt out of parallel tests on CI. */ 9 | reporter: 'html', 10 | use: { 11 | baseURL: 'https://localhost:4200', 12 | trace: 'on-first-retry', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 13 | }, 14 | 15 | projects: [ 16 | { 17 | name: 'chromium', 18 | use: { 19 | ...devices['Desktop Chrome'], 20 | launchOptions: { args: ['--ignore-certificate-errors'] } 21 | }, 22 | }, 23 | ], 24 | 25 | /* Run your local dev server before starting the tests */ 26 | webServer: { 27 | command: 'npm run start-with-ssl', 28 | url: 'https://localhost:4200', 29 | reuseExistingServer: !process.env.CI, 30 | ignoreHTTPSErrors: true, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /screenshot-001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/3c4b0faf062ca438e56b771e2cc9ca0680a1b485/screenshot-001.png -------------------------------------------------------------------------------- /src/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /src/app/app-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { AuthService } from './core/auth.service'; 5 | 6 | @Component({ 7 | selector: 'app-menu', 8 | template: ``, 33 | standalone: false 34 | }) 35 | export class AppMenuComponent { 36 | isAuthenticated$: Observable; 37 | 38 | constructor(private authService: AuthService) { 39 | this.isAuthenticated$ = authService.isAuthenticated$; 40 | } 41 | 42 | login() { 43 | this.authService.login(); 44 | } 45 | logout() { 46 | this.authService.logout(); 47 | } 48 | 49 | get email(): string { 50 | return this.authService.identityClaims 51 | ? (this.authService.identityClaims as any)['email'] 52 | : '-'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable brace-style */ 2 | /* eslint-disable max-len */ 3 | 4 | import { Component } from '@angular/core'; 5 | import { Observable } from 'rxjs'; 6 | 7 | import { AuthService } from './core/auth.service'; 8 | 9 | @Component({ 10 | selector: 'app-root', 11 | template: `
12 | 13 |
14 |

Welcome

15 |

This is part of the app.component. Below is the router outlet.

16 |
17 | 18 |
19 |

You can go to a url without a route to see the fallback route.

20 |
21 |

22 | 23 | 24 | 25 |

26 |

27 | 28 | 29 | 30 |

31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
IsAuthenticated{{isAuthenticated$ | async}}
HasValidToken{{hasValidToken}}
IsDoneLoading{{isDoneLoading$ | async}}
CanActivateProtectedRoutes{{canActivateProtectedRoutes$ | async}}
IdentityClaims{{identityClaims | json}}
RefreshToken{{refreshToken}}
AccessToken{{accessToken}}
IdToken{{idToken}}
42 |
43 |
`, 44 | standalone: false 45 | }) 46 | export class AppComponent { 47 | isAuthenticated$: Observable; 48 | isDoneLoading$: Observable; 49 | canActivateProtectedRoutes$: Observable; 50 | 51 | constructor( 52 | private authService: AuthService, 53 | ) { 54 | this.isAuthenticated$ = this.authService.isAuthenticated$; 55 | this.isDoneLoading$ = this.authService.isDoneLoading$; 56 | this.canActivateProtectedRoutes$ = this.authService.canActivateProtectedRoutes$; 57 | } 58 | 59 | login() { this.authService.login(); } 60 | logout() { this.authService.logout(); } 61 | refresh() { this.authService.refresh(); } 62 | reload() { window.location.reload(); } 63 | clearStorage() { localStorage.clear(); } 64 | 65 | logoutExternally() { 66 | window.open(this.authService.logoutUrl); 67 | } 68 | 69 | get hasValidToken() { return this.authService.hasValidToken(); } 70 | get accessToken() { return this.authService.accessToken; } 71 | get refreshToken() { return this.authService.refreshToken; } 72 | get identityClaims() { return this.authService.identityClaims; } 73 | get idToken() { return this.authService.idToken; } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { AppMenuComponent } from './app-menu.component'; 6 | import { AppComponent } from './app.component'; 7 | import { CoreModule } from './core/core.module'; 8 | import { FallbackComponent } from './fallback.component'; 9 | import { ShouldLoginComponent } from './should-login.component'; 10 | 11 | @NgModule({ 12 | declarations: [ 13 | AppComponent, 14 | AppMenuComponent, 15 | FallbackComponent, 16 | ShouldLoginComponent, 17 | ], 18 | imports: [ 19 | BrowserModule, 20 | CoreModule.forRoot(), 21 | RouterModule.forRoot([ 22 | { path: '', redirectTo: 'basics/home', pathMatch: 'full' }, 23 | // Note: this way of module loading requires this in your tsconfig.json: "module": "esnext" 24 | { path: 'basics', loadChildren: () => import('./feature-basics/basics.module').then(m => m.BasicsModule) }, 25 | { path: 'extras', loadChildren: () => import('./feature-extras/extras.module').then(m => m.ExtrasModule) }, 26 | { path: 'should-login', component: ShouldLoginComponent }, 27 | { path: '**', component: FallbackComponent }, 28 | ], {}) 29 | ], 30 | bootstrap: [AppComponent] 31 | }) 32 | export class AppModule { } 33 | -------------------------------------------------------------------------------- /src/app/core/auth-app-initializer.factory.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from './auth.service' 2 | 3 | export function authAppInitializerFactory(authService: AuthService): () => Promise { 4 | return () => authService.runInitialLoginSequence(); 5 | } 6 | -------------------------------------------------------------------------------- /src/app/core/auth-config.ts: -------------------------------------------------------------------------------- 1 | import { AuthConfig } from 'angular-oauth2-oidc'; 2 | 3 | export const authConfig: AuthConfig = { 4 | issuer: 'https://demo.duendesoftware.com', 5 | clientId: 'interactive.public', // The "Auth Code + PKCE" client 6 | responseType: 'code', 7 | redirectUri: window.location.origin + '/', 8 | silentRefreshRedirectUri: window.location.origin + '/silent-refresh.html', 9 | scope: 'openid profile email api', // Ask offline_access to support refresh token refreshes 10 | useSilentRefresh: true, // Needed for Code Flow to suggest using iframe-based refreshes 11 | silentRefreshTimeout: 5000, // For faster testing 12 | timeoutFactor: 0.25, // For faster testing 13 | sessionChecksEnabled: true, 14 | showDebugInformation: true, // Also requires enabling "Verbose" level in devtools 15 | clearHashAfterLogin: false, // https://github.com/manfredsteyer/angular-oauth2-oidc/issues/457#issuecomment-431807040, 16 | nonceStateSeparator : 'semicolon' // Real semicolon gets mangled by Duende ID Server's URI encoding 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/core/auth-guard-with-forced-login.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | import { filter, switchMap, tap } from 'rxjs/operators'; 5 | 6 | import { AuthService } from './auth.service'; 7 | 8 | @Injectable() 9 | export class AuthGuardWithForcedLogin { 10 | 11 | constructor( 12 | private authService: AuthService, 13 | ) { 14 | } 15 | 16 | canActivate( 17 | route: ActivatedRouteSnapshot, 18 | state: RouterStateSnapshot, 19 | ): Observable { 20 | return this.authService.isDoneLoading$.pipe( 21 | filter(isDone => isDone), 22 | switchMap(_ => this.authService.isAuthenticated$), 23 | tap(isAuthenticated => isAuthenticated || this.authService.login(state.url)), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/core/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | import { tap } from 'rxjs/operators'; 5 | 6 | import { AuthService } from './auth.service'; 7 | 8 | @Injectable() 9 | export class AuthGuard { 10 | constructor( 11 | private authService: AuthService, 12 | ) { } 13 | 14 | canActivate( 15 | route: ActivatedRouteSnapshot, 16 | state: RouterStateSnapshot, 17 | ): Observable { 18 | return this.authService.canActivateProtectedRoutes$ 19 | .pipe(tap(x => console.log('You tried to go to ' + state.url + ' and this guard said ' + x))); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/core/auth-module-config.ts: -------------------------------------------------------------------------------- 1 | import { OAuthModuleConfig } from 'angular-oauth2-oidc'; 2 | 3 | export const authModuleConfig: OAuthModuleConfig = { 4 | resourceServer: { 5 | allowedUrls: ['https://demo.duendesoftware.com/api'], 6 | sendAccessToken: true, 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/core/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { Router } from '@angular/router'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { EventType, OAuthEvent, OAuthService, OAuthSuccessEvent } from 'angular-oauth2-oidc'; 5 | import { Subject } from 'rxjs'; 6 | import { AuthService } from './auth.service'; 7 | 8 | class FakeComponent {} 9 | const loginUrl = '/should-login'; 10 | 11 | describe('AuthService', () => { 12 | let mockOAuthEvents: Subject; 13 | let mockOAuthService: jasmine.SpyObj; 14 | let router: Router; 15 | 16 | beforeEach(() => { 17 | mockOAuthEvents = new Subject(); 18 | mockOAuthService = jasmine.createSpyObj({ 19 | configure: void 0, 20 | hasValidAccessToken: false, 21 | loadDiscoveryDocument: Promise.resolve(new OAuthSuccessEvent('discovery_document_loaded')), 22 | loadDiscoveryDocumentAndLogin: Promise.resolve(false), 23 | loadDiscoveryDocumentAndTryLogin: Promise.resolve(false), 24 | loadUserProfile: Promise.resolve({ }), 25 | restartSessionChecksIfStillLoggedIn: void 0, 26 | setupAutomaticSilentRefresh: void 0, 27 | silentRefresh: Promise.resolve(new OAuthSuccessEvent('silently_refreshed')), 28 | stopAutomaticRefresh: void 0, 29 | tryLogin: Promise.resolve(false), 30 | tryLoginCodeFlow: Promise.resolve(void 0), 31 | tryLoginImplicitFlow: Promise.resolve(false), 32 | }, { 33 | events: mockOAuthEvents.asObservable(), 34 | }); 35 | 36 | TestBed.configureTestingModule({ 37 | imports: [RouterTestingModule.withRoutes([ 38 | { path: 'should-login', component: FakeComponent }, 39 | ])], 40 | providers: [ 41 | AuthService, 42 | { provide: OAuthService, useValue: mockOAuthService }, 43 | ] 44 | }); 45 | router = TestBed.inject(Router); 46 | spyOn(router, 'navigateByUrl'); 47 | }); 48 | 49 | describe('constructor', () => { 50 | // This set of tests needs to .inject(AuthService) after some 51 | // additional 'Arrange' phase stuff. So each test does it on 52 | // its own. 53 | 54 | it('should initialize isAuthenticated$ based on hasValidAccessToken===false', fakeAsync(() => { 55 | let latestIsAuthenticatedValue: any = null; 56 | mockOAuthService.hasValidAccessToken.and.returnValue(false); 57 | const service = TestBed.inject(AuthService); 58 | service.isAuthenticated$.subscribe(x => latestIsAuthenticatedValue = x); 59 | expect(latestIsAuthenticatedValue).toEqual(false); 60 | })); 61 | 62 | it('should initialize isAuthenticated$ based on hasValidAccessToken===true', fakeAsync(() => { 63 | let latestIsAuthenticatedValue: any = null; 64 | mockOAuthService.hasValidAccessToken.and.returnValue(true); 65 | const service = TestBed.inject(AuthService); 66 | service.isAuthenticated$.subscribe(x => latestIsAuthenticatedValue = x); 67 | expect(latestIsAuthenticatedValue).toEqual(true); 68 | })); 69 | }); 70 | 71 | describe('in general', () => { 72 | let service: AuthService; 73 | beforeEach(() => service = TestBed.inject(AuthService)); 74 | 75 | it('should react on OAuthService events', () => { 76 | mockOAuthEvents.next({type: 'silently_refreshed'}); 77 | mockOAuthEvents.next({type: 'token_received'}); 78 | 79 | expect(mockOAuthService.loadUserProfile).toHaveBeenCalled(); 80 | expect(mockOAuthService.hasValidAccessToken).toHaveBeenCalledTimes(3); // one extra time in constructor 81 | }); 82 | 83 | ['session_terminated', 'session_error'].forEach(eventType => { 84 | it(`should react on OAuthService event ${eventType} and redirect to login`, () => { 85 | mockOAuthEvents.next({type: eventType as EventType}); 86 | 87 | expect(router.navigateByUrl).toHaveBeenCalledWith(loginUrl); 88 | }); 89 | }); 90 | 91 | it('should handle storage event and update isAuthenticated status', () => { 92 | mockOAuthService.hasValidAccessToken.and.returnValue(false); 93 | 94 | window.dispatchEvent(new StorageEvent('storage', {key: 'access_token'})); 95 | 96 | expect(router.navigateByUrl).toHaveBeenCalledWith(loginUrl); 97 | service.isAuthenticated$.subscribe(isAuthenticated => expect(isAuthenticated).toBe(false)); 98 | }); 99 | }); 100 | 101 | describe('runInitialLoginSequence', () => { 102 | let service: AuthService; 103 | beforeEach(() => service = TestBed.inject(AuthService)); 104 | 105 | it('should login via hash if token is valid', waitForAsync (() => { 106 | mockOAuthService.hasValidAccessToken.and.returnValue(true); 107 | 108 | service.runInitialLoginSequence().then(() => { 109 | expect(mockOAuthService.tryLogin).toHaveBeenCalled(); 110 | expect(mockOAuthService.silentRefresh).not.toHaveBeenCalled(); 111 | }); 112 | })); 113 | 114 | it('should silent login via refresh and navigate to state url when required user interaction', waitForAsync (() => { 115 | mockOAuthService.state = '/some/url'; 116 | 117 | service.runInitialLoginSequence().then(() => { 118 | expect(mockOAuthService.tryLogin).toHaveBeenCalled(); 119 | expect(mockOAuthService.silentRefresh).toHaveBeenCalled(); 120 | expect(router.navigateByUrl).toHaveBeenCalledWith('/some/url'); 121 | }); 122 | })); 123 | 124 | it('should silent login via refresh without redirect', waitForAsync (() => { 125 | service.runInitialLoginSequence().then(() => { 126 | expect(mockOAuthService.tryLogin).toHaveBeenCalled(); 127 | expect(mockOAuthService.silentRefresh).toHaveBeenCalled(); 128 | expect(router.navigateByUrl).not.toHaveBeenCalled(); 129 | }); 130 | })); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/app/core/auth.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable brace-style */ 2 | 3 | import { Injectable } from '@angular/core'; 4 | import { Router } from '@angular/router'; 5 | import { OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc'; 6 | import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; 7 | import { filter, map } from 'rxjs/operators'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class AuthService { 11 | 12 | private isAuthenticatedSubject$ = new BehaviorSubject(false); 13 | public isAuthenticated$ = this.isAuthenticatedSubject$.asObservable(); 14 | 15 | private isDoneLoadingSubject$ = new BehaviorSubject(false); 16 | public isDoneLoading$ = this.isDoneLoadingSubject$.asObservable(); 17 | 18 | /** 19 | * Publishes `true` if and only if (a) all the asynchronous initial 20 | * login calls have completed or errorred, and (b) the user ended up 21 | * being authenticated. 22 | * 23 | * In essence, it combines: 24 | * 25 | * - the latest known state of whether the user is authorized 26 | * - whether the ajax calls for initial log in have all been done 27 | */ 28 | public canActivateProtectedRoutes$: Observable = combineLatest([ 29 | this.isAuthenticated$, 30 | this.isDoneLoading$ 31 | ]).pipe(map(values => values.every(b => b))); 32 | 33 | private navigateToLoginPage() { 34 | // TODO: Remember current URL 35 | this.router.navigateByUrl('/should-login'); 36 | } 37 | 38 | constructor( 39 | private oauthService: OAuthService, 40 | private router: Router, 41 | ) { 42 | // Useful for debugging: 43 | this.oauthService.events.subscribe(event => { 44 | if (event instanceof OAuthErrorEvent) { 45 | console.error('OAuthErrorEvent Object:', event); 46 | } else { 47 | console.warn('OAuthEvent Object:', event); 48 | } 49 | }); 50 | 51 | // THe following cross-tab communication of fresh access tokens works usually in practice, 52 | // but if you need more robust handling the community has come up with ways to extend logic 53 | // in the library which may give you better mileage. 54 | // 55 | // See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/issues/2 56 | // 57 | // Until then we'll stick to this: 58 | window.addEventListener('storage', (event) => { 59 | // The `key` is `null` if the event was caused by `.clear()` 60 | if (event.key !== 'access_token' && event.key !== null) { 61 | return; 62 | } 63 | 64 | console.warn('Noticed changes to access_token (most likely from another tab), updating isAuthenticated'); 65 | this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken()); 66 | 67 | if (!this.oauthService.hasValidAccessToken()) { 68 | this.navigateToLoginPage(); 69 | } 70 | }); 71 | 72 | this.oauthService.events 73 | .subscribe(_ => { 74 | this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken()); 75 | }); 76 | this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken()); 77 | 78 | this.oauthService.events 79 | .pipe(filter(e => ['token_received'].includes(e.type))) 80 | .subscribe(e => this.oauthService.loadUserProfile()); 81 | 82 | this.oauthService.events 83 | .pipe(filter(e => ['session_terminated', 'session_error'].includes(e.type))) 84 | .subscribe(e => this.navigateToLoginPage()); 85 | 86 | this.oauthService.setupAutomaticSilentRefresh(); 87 | } 88 | 89 | public runInitialLoginSequence(): Promise { 90 | if (location.hash) { 91 | console.log('Encountered hash fragment, plotting as table...'); 92 | console.table(location.hash.substr(1).split('&').map(kvp => kvp.split('='))); 93 | } 94 | 95 | // 0. LOAD CONFIG: 96 | // First we have to check to see how the IdServer is 97 | // currently configured: 98 | return this.oauthService.loadDiscoveryDocument() 99 | 100 | // For demo purposes, we pretend the previous call was very slow 101 | .then(() => new Promise(resolve => setTimeout(() => resolve(), 1500))) 102 | 103 | // 1. HASH LOGIN: 104 | // Try to log in via hash fragment after redirect back 105 | // from IdServer from initImplicitFlow: 106 | .then(() => this.oauthService.tryLogin()) 107 | 108 | .then(() => { 109 | if (this.oauthService.hasValidAccessToken()) { 110 | return Promise.resolve(); 111 | } 112 | 113 | // 2. SILENT LOGIN: 114 | // Try to log in via a refresh because then we can prevent 115 | // needing to redirect the user: 116 | return this.oauthService.silentRefresh() 117 | .then(() => Promise.resolve()) 118 | .catch(result => { 119 | // Subset of situations from https://openid.net/specs/openid-connect-core-1_0.html#AuthError 120 | // Only the ones where it's reasonably sure that sending the 121 | // user to the IdServer will help. 122 | const errorResponsesRequiringUserInteraction = [ 123 | 'interaction_required', 124 | 'login_required', 125 | 'account_selection_required', 126 | 'consent_required', 127 | ]; 128 | 129 | if (result 130 | && result.reason 131 | && errorResponsesRequiringUserInteraction.indexOf(result.reason.error) >= 0) { 132 | 133 | // 3. ASK FOR LOGIN: 134 | // At this point we know for sure that we have to ask the 135 | // user to log in, so we redirect them to the IdServer to 136 | // enter credentials. 137 | // 138 | // Enable this to ALWAYS force a user to login. 139 | // this.login(); 140 | // 141 | // Instead, we'll now do this: 142 | console.warn('User interaction is needed to log in, we will wait for the user to manually log in.'); 143 | return Promise.resolve(); 144 | } 145 | 146 | // We can't handle the truth, just pass on the problem to the 147 | // next handler. 148 | return Promise.reject(result); 149 | }); 150 | }) 151 | 152 | .then(() => { 153 | this.isDoneLoadingSubject$.next(true); 154 | 155 | // Check for the strings 'undefined' and 'null' just to be sure. Our current 156 | // login(...) should never have this, but in case someone ever calls 157 | // initImplicitFlow(undefined | null) this could happen. 158 | if (this.oauthService.state && this.oauthService.state !== 'undefined' && this.oauthService.state !== 'null') { 159 | let stateUrl = this.oauthService.state; 160 | if (stateUrl.startsWith('/') === false) { 161 | stateUrl = decodeURIComponent(stateUrl); 162 | } 163 | console.log(`There was state of ${this.oauthService.state}, so we are sending you to: ${stateUrl}`); 164 | this.router.navigateByUrl(stateUrl); 165 | } 166 | }) 167 | .catch(() => this.isDoneLoadingSubject$.next(true)); 168 | } 169 | 170 | public login(targetUrl?: string) { 171 | // Note: before version 9.1.0 of the library you needed to 172 | // call encodeURIComponent on the argument to the method. 173 | this.oauthService.initLoginFlow(targetUrl || this.router.url); 174 | } 175 | 176 | public logout() { this.oauthService.logOut(); } 177 | public refresh() { this.oauthService.silentRefresh(); } 178 | public hasValidToken() { return this.oauthService.hasValidAccessToken(); } 179 | 180 | // These normally won't be exposed from a service like this, but 181 | // for debugging it makes sense. 182 | public get accessToken() { return this.oauthService.getAccessToken(); } 183 | public get refreshToken() { return this.oauthService.getRefreshToken(); } 184 | public get identityClaims() { return this.oauthService.getIdentityClaims(); } 185 | public get idToken() { return this.oauthService.getIdToken(); } 186 | public get logoutUrl() { return this.oauthService.logoutUrl; } 187 | } 188 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 2 | import { ModuleWithProviders, NgModule, Optional, SkipSelf, inject, provideAppInitializer } from '@angular/core'; 3 | import { AuthConfig, OAuthModule, OAuthModuleConfig, OAuthStorage } from 'angular-oauth2-oidc'; 4 | import { authAppInitializerFactory } from './auth-app-initializer.factory'; 5 | import { authConfig } from './auth-config'; 6 | import { AuthGuardWithForcedLogin } from './auth-guard-with-forced-login.service'; 7 | import { AuthGuard } from './auth-guard.service'; 8 | import { authModuleConfig } from './auth-module-config'; 9 | import { AuthService } from './auth.service'; 10 | 11 | // We need a factory since localStorage is not available at AOT build time 12 | export function storageFactory(): OAuthStorage { 13 | return localStorage; 14 | } 15 | 16 | @NgModule({ 17 | imports: [OAuthModule.forRoot()], providers: [ 18 | AuthService, 19 | AuthGuard, 20 | AuthGuardWithForcedLogin, 21 | provideHttpClient(withInterceptorsFromDi()), 22 | ] 23 | }) 24 | export class CoreModule { 25 | static forRoot(): ModuleWithProviders { 26 | return { 27 | ngModule: CoreModule, 28 | providers: [ 29 | provideAppInitializer(() => { 30 | const initializerFn = (authAppInitializerFactory)(inject(AuthService)); 31 | return initializerFn(); 32 | }), 33 | { provide: AuthConfig, useValue: authConfig }, 34 | { provide: OAuthModuleConfig, useValue: authModuleConfig }, 35 | { provide: OAuthStorage, useFactory: storageFactory }, 36 | ] 37 | }; 38 | } 39 | 40 | constructor(@Optional() @SkipSelf() parentModule: CoreModule) { 41 | if (parentModule) { 42 | throw new Error('CoreModule is already loaded. Import it in the AppModule only'); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/fallback.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-fallback', 5 | template: `

This is the 🕳️ FALLBACK component.

`, 6 | standalone: false 7 | }) 8 | export class FallbackComponent { 9 | } 10 | -------------------------------------------------------------------------------- /src/app/feature-basics/admin1.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { ApiService } from '../shared/api.service'; 5 | 6 | @Component({ 7 | selector: 'app-admin', 8 | template: `

9 | This is the ⚙ ADMIN component. 10 | It will not redirect you to the login server. 11 | - {{ apiResponse | async }} 12 |

`, 13 | standalone: false 14 | }) 15 | export class Admin1Component implements OnInit { 16 | apiResponse!: Observable; 17 | 18 | constructor(private apiService: ApiService) { } 19 | 20 | ngOnInit() { 21 | this.apiResponse = this.apiService.getProtectedApiResponse(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/feature-basics/basics.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { AuthGuard } from '../core/auth-guard.service'; 6 | import { ApiService } from '../shared/api.service'; 7 | import { SharedModule } from '../shared/shared.module'; 8 | import { Admin1Component } from './admin1.component'; 9 | import { HomeComponent } from './home.component'; 10 | import { PublicComponent } from './public.component'; 11 | 12 | @NgModule({ 13 | declarations: [ 14 | Admin1Component, 15 | HomeComponent, 16 | PublicComponent, 17 | ], 18 | imports: [ 19 | CommonModule, 20 | SharedModule, 21 | RouterModule.forChild([ 22 | { path: '', redirectTo: 'home', pathMatch: 'full' }, 23 | { path: 'home', component: HomeComponent }, 24 | { path: 'admin1', component: Admin1Component, canActivate: [AuthGuard] }, 25 | { path: 'public', component: PublicComponent }, 26 | ]), 27 | ], 28 | providers: [ 29 | ApiService, 30 | ], 31 | }) 32 | export class BasicsModule { } 33 | -------------------------------------------------------------------------------- /src/app/feature-basics/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { ApiService } from '../shared/api.service'; 5 | 6 | @Component({ 7 | selector: 'app-home', 8 | template: `

9 | This is the 🏠 HOME component. 10 | - {{ apiResponse | async }} 11 |

`, 12 | standalone: false 13 | }) 14 | export class HomeComponent implements OnInit { 15 | apiResponse!: Observable; 16 | 17 | constructor(private apiService: ApiService) { } 18 | 19 | ngOnInit() { 20 | this.apiResponse = this.apiService.getProtectedApiResponse(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/feature-basics/public.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-public', 5 | template: `

This is the 🌐 PUBLIC component.

`, 6 | standalone: false 7 | }) 8 | export class PublicComponent { 9 | } 10 | -------------------------------------------------------------------------------- /src/app/feature-extras/admin2.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { ApiService } from '../shared/api.service'; 5 | 6 | @Component({ 7 | selector: 'app-admin', 8 | template: `

9 | This is the 🔧 ADMIN 2 component. 10 | It will redirect you to login if needed. 11 | - {{ apiResponse | async }} 12 |

`, 13 | standalone: false 14 | }) 15 | export class Admin2Component implements OnInit { 16 | apiResponse!: Observable; 17 | 18 | constructor(private apiService: ApiService) { } 19 | 20 | ngOnInit() { 21 | this.apiResponse = this.apiService.getProtectedApiResponse(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/feature-extras/extras.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { RouterModule } from '@angular/router'; 4 | 5 | import { AuthGuardWithForcedLogin } from '../core/auth-guard-with-forced-login.service'; 6 | import { SharedModule } from '../shared/shared.module'; 7 | import { Admin2Component } from './admin2.component'; 8 | 9 | @NgModule({ 10 | declarations: [ 11 | Admin2Component, 12 | ], 13 | imports: [ 14 | CommonModule, 15 | SharedModule, 16 | RouterModule.forChild([ 17 | { path: 'admin2', component: Admin2Component, canActivate: [AuthGuardWithForcedLogin] }, 18 | ]), 19 | ], 20 | }) 21 | export class ExtrasModule { } 22 | -------------------------------------------------------------------------------- /src/app/shared/api.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, map } from 'rxjs/operators'; 5 | 6 | @Injectable() 7 | export class ApiService { 8 | constructor(private http: HttpClient) { } 9 | 10 | getProtectedApiResponse(): Observable { 11 | return this.http.get('https://demo.duendesoftware.com/api/test') 12 | .pipe( 13 | map(response => response.find((i: any) => i.type === 'iss').value), 14 | map(iss => '☁ API Success from ' + iss), 15 | catchError((e: HttpErrorResponse) => of(`🌩 API Error: ${e.status} ${e.statusText}`)), 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { ApiService } from './api.service'; 4 | 5 | @NgModule({ 6 | providers: [ 7 | ApiService, 8 | ] 9 | }) 10 | export class SharedModule { } 11 | -------------------------------------------------------------------------------- /src/app/should-login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { OAuthService } from 'angular-oauth2-oidc'; 3 | 4 | @Component({ 5 | selector: 'app-should-login', 6 | template: `

You need to be logged in to view requested page.

7 |

Please log in before continuing.

`, 8 | standalone: false 9 | }) 10 | export class ShouldLoginComponent { 11 | constructor(private authService: OAuthService) { } 12 | 13 | public login($event: any) { 14 | $event.preventDefault(); 15 | this.authService.initLoginFlow(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/3c4b0faf062ca438e56b771e2cc9ca0680a1b485/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * In development mode, to ignore zone related error stack frames such as 11 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 12 | * import the following file, but please comment it out in production mode 13 | * because it will have performance impact when throw error 14 | */ 15 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 16 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/3c4b0faf062ca438e56b771e2cc9ca0680a1b485/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SampleAuthGuards 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Initializing...
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.log(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /src/silent-refresh.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .authenticating-loader { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | position: fixed; 6 | top: 0; 7 | right: 0; 8 | bottom: 0; 9 | left: 0; 10 | font-size: 5rem; 11 | background: #fff; 12 | opacity: 0.8; 13 | animation: pulsate 0.4s infinite alternate linear; 14 | } 15 | 16 | @keyframes pulsate { 17 | from { opacity: 1.0; } 18 | to { opacity: 0.2; } 19 | } 20 | 21 | .pre { 22 | white-space: pre; 23 | } 24 | 25 | .break-all { 26 | word-break: break-all; 27 | } 28 | 29 | .table-props tr th { 30 | width: 1px; 31 | } 32 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js'; 2 | import 'zone.js/testing'; 3 | import { getTestBed } from '@angular/core/testing'; 4 | import { 5 | BrowserDynamicTestingModule, 6 | platformBrowserDynamicTesting 7 | } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | // First, initialize the Angular testing environment. 10 | getTestBed().initTestEnvironment( 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting(), { 13 | teardown: { destroyAfterEach: false } 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "main.ts", 9 | "polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /ssl/certificate.cnf: -------------------------------------------------------------------------------- 1 | [req] 2 | default_bits = 2048 3 | prompt = no 4 | default_md = sha256 5 | x509_extensions = v3_req 6 | distinguished_name = dn 7 | 8 | [dn] 9 | C = NL 10 | O = My Organisation 11 | emailAddress = dummy@example.org 12 | CN = localhost 13 | 14 | [v3_req] 15 | subjectAltName = @alt_names 16 | 17 | [alt_names] 18 | DNS.1 = localhost 19 | -------------------------------------------------------------------------------- /ssl/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDYDCCAkigAwIBAgIUTfJWjSStUX6F226OgZS7L4DT8lowDQYJKoZIhvcNAQEL 3 | BQAwXTELMAkGA1UEBhMCTkwxGDAWBgNVBAoMD015IE9yZ2FuaXNhdGlvbjEgMB4G 4 | CSqGSIb3DQEJARYRZHVtbXlAZXhhbXBsZS5vcmcxEjAQBgNVBAMMCWxvY2FsaG9z 5 | dDAeFw0yMTAzMjExNDUxMjdaFw0zMDEyMTkxNDUxMjdaMF0xCzAJBgNVBAYTAk5M 6 | MRgwFgYDVQQKDA9NeSBPcmdhbmlzYXRpb24xIDAeBgkqhkiG9w0BCQEWEWR1bW15 7 | QGV4YW1wbGUub3JnMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB 8 | AQUAA4IBDwAwggEKAoIBAQCjy25xpyvu8V7rjHWFz127WwvvIdhWWNfTfyPqLR+g 9 | VQyNGxfSHn6j6gXXH+w1AC3PIU04kYpdNLeM8bzlQT+yECfQacZw7ICYoOBmM5mY 10 | d8qdORwF9H7AzHUiP29oKxyhLy9h0XwMuSAUpHuRIRzXVuxOzUgiJLYlREVTYk++ 11 | kfiUEbxVEnHfp+miC6iMcKX0+O+hhc9UMihmFPWeUDzakoaQXDN5+ukvQaM/9/3C 12 | K8g6cZh3a0naT/W7krnievU7iMBpkPhTN3F9zsjpkx1WyqNZtWaQ8DzSVhU+blvp 13 | txbBMmKoeT9nTiFxtT2IhmnpdkRzqVgbASiUhl0Ey189AgMBAAGjGDAWMBQGA1Ud 14 | EQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAYvO1tHgBBZgZcDF/ 15 | lygtsFT7FUo8IAXgwd+u5I1M1BdWGT63OZWefae8PucAQjt5lHqzeFd0Muex2sf0 16 | 5GO97wt5HiG0hsOVUsKfuS5Vzh8augVU3Z5nEkaBKX6xbRi5zhGBBdpPQ3ksCcdq 17 | zKwD/lb/4MYuGzxUb7lBKfnB3dwBYlve+Y+gaHQ/FFuCxESBNOzW9lUdNH6GR75J 18 | F+XLEgyWGsq76grVlstKhBHm/LhXrWYMC0gOO+wP1ZDCpbI5SZeKjLZLlDXdDgPq 19 | pNc/VF+zCbf6yNnrGmszomV6oPv//6Q7jWxPVimqjYYr14W87XySLtUmjLnVgfnE 20 | xmeURA== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /ssl/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCjy25xpyvu8V7r 3 | jHWFz127WwvvIdhWWNfTfyPqLR+gVQyNGxfSHn6j6gXXH+w1AC3PIU04kYpdNLeM 4 | 8bzlQT+yECfQacZw7ICYoOBmM5mYd8qdORwF9H7AzHUiP29oKxyhLy9h0XwMuSAU 5 | pHuRIRzXVuxOzUgiJLYlREVTYk++kfiUEbxVEnHfp+miC6iMcKX0+O+hhc9UMihm 6 | FPWeUDzakoaQXDN5+ukvQaM/9/3CK8g6cZh3a0naT/W7krnievU7iMBpkPhTN3F9 7 | zsjpkx1WyqNZtWaQ8DzSVhU+blvptxbBMmKoeT9nTiFxtT2IhmnpdkRzqVgbASiU 8 | hl0Ey189AgMBAAECggEARhrB8J8ObLyeVc7OVjFE+vWjC/y+bWalKX6XTpl1mdgN 9 | ATABaAtpRJrbWRHutViYQrkMJhQU1oPDs+2bXbwpmPrVL5y2NIrlF25z3QpkyR8s 10 | d1KELgBRaA+aasgf1MPvAwYBD6rrnz50/qDPynQTtg8cITY3k3WHCDhKO8AuRW/h 11 | UdEFTf6pt8wwACluwi5w7ktp7ebUJZoIzgBCI35XjT45KawzVP9PSUzY0leFGUBs 12 | h50RxxoH7KaTsY+F2Xuvb7NUPj5kk6Douh2vJr6zm/A84W3gIF4CnyaxLIep7yP4 13 | vtNnahdCtdHjfhro4g/eiJyuCPcvWIhKFleLaGamOQKBgQDRbLB+EU7Avklft+wm 14 | gaakAzTki7fHLd9eeiLlyOyic+VOS5hyjclsvdQ3iFZLXFoztJHkAxHWD0r44mln 15 | d/NbEMUMxMeB62IvAH666zp9+JIrsxZQRC4veNDS2kMUYHyrqvoyaxQy7xrXdjzv 16 | 7vOdXsi9rOQxVjjqvNi10SLmzwKBgQDIONz9ODXi1FLgd7e7CCr+mOy6OugPcm/v 17 | bnbCwL2LgqR5DMUYXMXO8Q1RJe6e4MtW78OLv5vZCXDJl58iCUNYbMdtBro2s7dI 18 | 7GprOf+r/k1EukqY9BVHAC2DfS+6nVgP+msqzhS+9PkkDWi5ZNn/vMDZwiQVRDqS 19 | zhSfCwtcMwKBgQCOFrFUh4eoLDL6N36IzbRzWR2c5tLk19HSdwmQYBd1TS7KRW4E 20 | YwDDv7PpjZ4G+XzV0fCeBBso4i1a2brsEa1SHvmi5Sv7kOmHvE0/ovOHnowGDDba 21 | dLflS5JbTOzwOVq82n9wj8gfmqzafQVxQO2W20VVs6ULeFWbpk9eZKvKbwKBgH/+ 22 | Uc8SKuhpQN27ylkm3I1K5zIZzBVr24CdBhBzs/fGOSwk7K4pSE6FLDUu4X7xRyu5 23 | NDFW+OLitRY59gGFGGcjisz4mvuzITzd3R3UwsuJUo3X5S4oAp4T0ASZd3R9dzkn 24 | pXCEsyBrbAUPCV5SyUVBvaq2/+gUaLlGQkp1ffqfAoGAXXfXkDrk9jgpp8ZDxTK9 25 | f9B0ZDbjzkIQrWtO2hGSeq/k6eopz7WSf8LMjx1hwy4VgmTPK3Tv30UQFAmufTVy 26 | Hpa+MLsX7dzmoXItLF5PmrvO+UEp64177Spyc7gv/qwvGpgmBgrSdad9DOh+BQHy 27 | OqUEJpClFGlV18nrat/Nnyk= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "experimentalDecorators": true, 14 | "moduleResolution": "node", 15 | "importHelpers": true, 16 | "target": "ES2022", 17 | "module": "es2020", 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ], 22 | "useDefineForClassFields": false 23 | }, 24 | "angularCompilerOptions": { 25 | "strictTemplates": true 26 | } 27 | } 28 | --------------------------------------------------------------------------------