├── angular ├── src │ ├── assets │ │ ├── .gitkeep │ │ ├── android-icon-72x72.png │ │ └── apple-icon-180x180.png │ ├── app │ │ ├── home │ │ │ ├── home.component.css │ │ │ ├── home.component.ts │ │ │ └── home.component.html │ │ ├── demo-apis │ │ │ ├── demo-apis.component.css │ │ │ ├── demo-apis.component.html │ │ │ └── demo-apis.component.ts │ │ ├── management │ │ │ ├── management.component.css │ │ │ ├── management.component.html │ │ │ ├── management.component.ts │ │ │ ├── management-routing.module.ts │ │ │ └── management.module.ts │ │ ├── core │ │ │ ├── index.ts │ │ │ ├── models │ │ │ │ └── application-user.ts │ │ │ ├── services │ │ │ │ ├── app-initializer.ts │ │ │ │ └── auth.service.ts │ │ │ ├── guards │ │ │ │ └── auth.guard.ts │ │ │ ├── interceptors │ │ │ │ ├── jwt.interceptor.ts │ │ │ │ └── unauthorized.interceptor.ts │ │ │ └── core.module.ts │ │ ├── app.module.ts │ │ ├── app-routing.module.ts │ │ ├── login │ │ │ ├── login.component.html │ │ │ ├── login.component.css │ │ │ └── login.component.ts │ │ └── app.component.ts │ ├── favicon.ico │ ├── environments │ │ ├── environment.ts │ │ └── environment.development.ts │ ├── styles.css │ ├── main.ts │ └── index.html ├── .vscode │ ├── settings.json │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── tsconfig.app.json ├── .dockerignore ├── Dockerfile ├── .gitignore ├── nginx │ ├── gzip.conf │ └── nginx.conf ├── README.md ├── package.json ├── tsconfig.json └── angular.json ├── jwt-angular-app.gif ├── localhost_5001.png ├── localhost_8080.png ├── webapi ├── https │ ├── aspnetapp.pfx │ └── README.md ├── .dockerignore ├── JwtAuthDemo │ ├── appsettings.Development.json │ ├── JwtAuthDemo.csproj │ ├── appsettings.json │ ├── Controllers │ │ ├── ValuesController.cs │ │ ├── WeatherForecastController.cs │ │ └── AccountController.cs │ ├── Infrastructure │ │ ├── JwtTokenConfig.cs │ │ ├── JwtRefreshTokenCache.cs │ │ └── JwtAuthManager.cs │ ├── Program.cs │ ├── Services │ │ └── UsersService.cs │ └── Startup.cs ├── Dockerfile ├── README.md ├── JwtAuthDemo.IntegrationTests │ ├── TestHostFixture .cs │ ├── JwtAuthDemo.IntegrationTests.csproj │ ├── JwtAuthManagerTests.cs │ ├── ValuesControllerTests.cs │ └── AccountControllerTests.cs └── JwtAuthDemo.sln ├── docker-compose.yml ├── .github └── FUNDING.yml ├── LICENSE ├── README.md └── .gitignore /angular/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /angular/src/app/home/home.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /angular/src/app/demo-apis/demo-apis.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /angular/src/app/management/management.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jwt-angular-app.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/jwt-angular-app.gif -------------------------------------------------------------------------------- /localhost_5001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/localhost_5001.png -------------------------------------------------------------------------------- /localhost_8080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/localhost_8080.png -------------------------------------------------------------------------------- /angular/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/angular/src/favicon.ico -------------------------------------------------------------------------------- /webapi/https/aspnetapp.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/webapi/https/aspnetapp.pfx -------------------------------------------------------------------------------- /angular/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "devkit", 4 | "signin", 5 | "wwwroot" 6 | ] 7 | } -------------------------------------------------------------------------------- /angular/src/assets/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/angular/src/assets/android-icon-72x72.png -------------------------------------------------------------------------------- /angular/src/assets/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet-labs/JwtAuthDemo/HEAD/angular/src/assets/apple-icon-180x180.png -------------------------------------------------------------------------------- /angular/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiUrl: 'https://localhost:5001/', 4 | }; 5 | -------------------------------------------------------------------------------- /angular/src/app/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './guards/auth.guard'; 2 | export * from './services/auth.service'; 3 | export * from './models/application-user'; 4 | -------------------------------------------------------------------------------- /angular/src/environments/environment.development.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiUrl: 'https://localhost:5001/', 4 | }; 5 | -------------------------------------------------------------------------------- /angular/src/app/core/models/application-user.ts: -------------------------------------------------------------------------------- 1 | export interface ApplicationUser { 2 | username: string; 3 | role: string; 4 | originalUserName: string; 5 | } 6 | -------------------------------------------------------------------------------- /angular/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /angular/src/app/management/management.component.html: -------------------------------------------------------------------------------- 1 |
2 |

TODO: impersonation related components

3 |

management works!

4 |
5 | -------------------------------------------------------------------------------- /webapi/.dockerignore: -------------------------------------------------------------------------------- 1 | # directories 2 | **/bin/ 3 | **/obj/ 4 | **/out/ 5 | **/.git/ 6 | **/.vs/ 7 | **/TestResults/ 8 | **/node_modules/ 9 | 10 | # files 11 | Dockerfile* 12 | **/*.md -------------------------------------------------------------------------------- /angular/src/styles.css: -------------------------------------------------------------------------------- 1 | @import 'bootstrap/dist/css/bootstrap.min.css'; 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | width: 100%; 7 | margin: 0; 8 | overflow: hidden; 9 | background-color: #fdfdfd; 10 | } 11 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /angular/src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule) 7 | .catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /angular/src/app/core/services/app-initializer.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { AuthService } from './auth.service'; 3 | 4 | export function appInitializer( 5 | authService: AuthService 6 | ): () => Observable { 7 | return () => authService.refreshToken(); 8 | } 9 | -------------------------------------------------------------------------------- /angular/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /angular/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | JWT Auth Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /angular/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /angular/.dockerignore: -------------------------------------------------------------------------------- 1 | # editor configs 2 | Properties 3 | .vscode 4 | .editorconfig 5 | .prettierrc 6 | .stylelintrc.json 7 | 8 | # code binaries 9 | bin 10 | obj 11 | node_modules 12 | npm-debug.log 13 | log 14 | 15 | # docs 16 | *.md 17 | LICENSE 18 | 19 | # docker 20 | .docker 21 | .dockerignore 22 | Dockerfile 23 | */docker-compose* 24 | 25 | # repositories 26 | .git 27 | .hg 28 | .svn -------------------------------------------------------------------------------- /angular/src/app/management/management.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-management', 5 | templateUrl: './management.component.html', 6 | styleUrls: ['./management.component.css'] 7 | }) 8 | export class ManagementComponent implements OnInit { 9 | 10 | constructor() { } 11 | 12 | ngOnInit(): void { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /angular/src/app/management/management-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | 4 | import { ManagementComponent } from './management.component'; 5 | 6 | const routes: Routes = [{ path: '', component: ManagementComponent }]; 7 | 8 | @NgModule({ 9 | imports: [RouterModule.forChild(routes)], 10 | exports: [RouterModule], 11 | }) 12 | export class ManagementRoutingModule {} 13 | -------------------------------------------------------------------------------- /angular/src/app/management/management.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { ManagementRoutingModule } from './management-routing.module'; 5 | import { ManagementComponent } from './management.component'; 6 | 7 | 8 | @NgModule({ 9 | declarations: [ManagementComponent], 10 | imports: [ 11 | CommonModule, 12 | ManagementRoutingModule 13 | ] 14 | }) 15 | export class ManagementModule { } 16 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/JwtAuthDemo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "jwtTokenConfig": { 11 | "secret": "VH8c5X1iI8Zm+Y4aFDCDCWSyKpOBzBVPtt0rtzCjExCeuE6dEnOPp3guWGGp1ZGipOtK93dS2JhZHv3G", 12 | "issuer": "https://mywebapi.com", 13 | "audience": "https://mywebapi.com", 14 | "accessTokenExpiration": 20, 15 | "refreshTokenExpiration": 60 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /angular/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as builder 2 | 3 | WORKDIR /app 4 | COPY package.json package-lock.json ./ 5 | ENV CI=1 6 | RUN npm ci 7 | 8 | COPY . . 9 | RUN npm run build -- --output-path=/dist 10 | 11 | # Deploy our Angular app to NGINX 12 | FROM nginx:alpine 13 | 14 | ## Replace the default nginx index page with our Angular app 15 | RUN rm -rf /usr/share/nginx/html/* 16 | COPY --from=builder /dist/browser /usr/share/nginx/html 17 | 18 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf 19 | COPY ./nginx/gzip.conf /etc/nginx/gzip.conf 20 | 21 | ENTRYPOINT ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /angular/src/app/demo-apis/demo-apis.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Get Values

5 | 8 |
9 |
10 |
    11 |
  • {{ item }}
  • 12 |
13 |
14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Controllers/ValuesController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace JwtAuthDemo.Controllers; 5 | 6 | [Route("api/[controller]")] 7 | [ApiController] 8 | [Authorize] 9 | public class ValuesController(ILogger logger) : ControllerBase 10 | { 11 | [HttpGet] 12 | public IEnumerable Get() 13 | { 14 | var userName = User.Identity?.Name!; 15 | logger.LogInformation("User [{userName}] is viewing values.", userName); 16 | return new[] { "value1", "value2" }; 17 | } 18 | } -------------------------------------------------------------------------------- /angular/src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { AuthService } from '../core'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | templateUrl: './home.component.html', 7 | styleUrls: ['./home.component.css'], 8 | }) 9 | export class HomeComponent implements OnInit { 10 | accessToken = ''; 11 | refreshToken = ''; 12 | 13 | constructor(public authService: AuthService) {} 14 | 15 | ngOnInit(): void { 16 | this.accessToken = localStorage.getItem('access_token') ?? ''; 17 | this.refreshToken = localStorage.getItem('refresh_token') ?? ''; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webapi/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VERSION=8.0-alpine 2 | 3 | FROM mcr.microsoft.com/dotnet/sdk:$VERSION AS build 4 | WORKDIR /app 5 | 6 | COPY ./*.sln . 7 | COPY ./JwtAuthDemo/*.csproj ./JwtAuthDemo/ 8 | COPY ./JwtAuthDemo.IntegrationTests/*.csproj ./JwtAuthDemo.IntegrationTests/ 9 | RUN dotnet restore 10 | 11 | COPY . . 12 | 13 | WORKDIR /app/JwtAuthDemo.IntegrationTests 14 | RUN dotnet test --no-restore 15 | 16 | WORKDIR /app/JwtAuthDemo 17 | RUN dotnet publish -c Release -o /out --no-restore 18 | 19 | 20 | FROM mcr.microsoft.com/dotnet/aspnet:$VERSION AS runtime 21 | WORKDIR /app 22 | COPY --from=build /out ./ 23 | ENTRYPOINT ["dotnet", "JwtAuthDemo.dll"] -------------------------------------------------------------------------------- /webapi/https/README.md: -------------------------------------------------------------------------------- 1 | # Create SSL Certificate 2 | 3 | [document](https://docs.microsoft.com/en-us/aspnet/core/security/docker-compose-https) 4 | 5 | ## Windows using Linux containers 6 | 7 | Generate certificate and configure local machine: 8 | 9 | ```bash 10 | # in the current folder 11 | dotnet dev-certs https -ep aspnetapp.pfx -p mypassword123 12 | dotnet dev-certs https --trust 13 | 14 | 15 | ``` 16 | 17 | ```powershell 18 | dotnet dev-certs https -ep $env:USERPROFILE\.aspnet\https\aspnetapp.pfx -p mypassword123 19 | ``` 20 | 21 | ```cmd 22 | dotnet dev-certs https -ep %USERPROFILE%\.aspnet\https\aspnetapp.pfx -p mypassword123 23 | ``` 24 | -------------------------------------------------------------------------------- /angular/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Infrastructure/JwtTokenConfig.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace JwtAuthDemo.Infrastructure; 4 | 5 | public class JwtTokenConfig 6 | { 7 | [JsonPropertyName("secret")] public string Secret { get; set; } = string.Empty; 8 | 9 | [JsonPropertyName("issuer")] public string Issuer { get; set; } = string.Empty; 10 | 11 | [JsonPropertyName("audience")] public string Audience { get; set; } = string.Empty; 12 | 13 | [JsonPropertyName("accessTokenExpiration")] public int AccessTokenExpiration { get; set; } 14 | 15 | [JsonPropertyName("refreshTokenExpiration")] public int RefreshTokenExpiration { get; set; } 16 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | angular: 5 | build: ./angular 6 | ports: 7 | - '8080:80' 8 | depends_on: 9 | - api 10 | restart: always 11 | 12 | api: 13 | build: ./webapi 14 | ports: 15 | - '5001:5001' 16 | environment: 17 | - ASPNETCORE_ENVIRONMENT=Development 18 | - ASPNETCORE_URLS=https://+:5001;http://+:5000 19 | - ASPNETCORE_HTTPS_PORT=5001 20 | - ASPNETCORE_Kestrel__Certificates__Default__Password=mypassword123 21 | - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx 22 | volumes: 23 | - ./webapi/https/aspnetapp.pfx:/https/aspnetapp.pfx:ro 24 | restart: always 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: changhuixu 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /angular/.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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /webapi/README.md: -------------------------------------------------------------------------------- 1 | # JWT Auth Demo 2 | 3 | This repository demos an ASP.NET Core web API application using JWT auth, and an integration testing project for a set of actions including login, logout, refresh token, impersonation, authentication, and authorization. 4 | 5 | ## Usage 6 | 7 | 1. Run in Visual Studio or in VS Code 8 | 9 | ```cmd 10 | dotnet watch run 11 | ``` 12 | 13 | 1. Run with Docker: restore NuGet packages, run tests, publish web API app, and build a Docker image. 14 | 15 | ```Docker 16 | docker build -t jwtauthdemo_api . 17 | ``` 18 | 19 | recommend to use the `docker-compose.yml` file in the parent directory to launch the app. 20 | 21 | ## JWT secret 22 | 23 | Generate a secret string 24 | 25 | ```bash 26 | $ openssl rand -base64 60 27 | ``` 28 | -------------------------------------------------------------------------------- /angular/nginx/gzip.conf: -------------------------------------------------------------------------------- 1 | gzip on; 2 | gzip_vary on; 3 | gzip_http_version 1.0; 4 | gzip_comp_level 5; 5 | gzip_types 6 | application/atom+xml 7 | application/javascript 8 | application/json 9 | application/rss+xml 10 | application/vnd.ms-fontobject 11 | application/x-font-ttf 12 | application/x-web-app-manifest+json 13 | application/xhtml+xml 14 | application/xml 15 | font/opentype 16 | image/svg+xml 17 | image/x-icon 18 | text/css 19 | text/plain 20 | text/x-component; 21 | gzip_proxied no-cache no-store private expired auth; 22 | gzip_min_length 256; 23 | gunzip on; -------------------------------------------------------------------------------- /angular/src/app/core/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { Router, CanMatchFn, Route, UrlSegment } from '@angular/router'; 3 | import { AuthService } from '../services/auth.service'; 4 | import { map } from 'rxjs/operators'; 5 | 6 | export const authGuard: CanMatchFn = (route: Route, _: UrlSegment[]) => { 7 | const router = inject(Router); 8 | const authService = inject(AuthService); 9 | 10 | const navigation = router.getCurrentNavigation(); 11 | 12 | const returnUrl = navigation?.extractedUrl.toString() || '/'; 13 | return authService.user$.pipe( 14 | map((user) => { 15 | if (user) { 16 | return true; 17 | } else { 18 | router.navigate(['login'], { 19 | queryParams: { returnUrl }, 20 | }); 21 | return false; 22 | } 23 | }) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /angular/README.md: -------------------------------------------------------------------------------- 1 | # Angular JWT Demo 2 | 3 | This is a regular Angular project, which is created by Angular CLI. This project demonstrates the JWT communications with a backend web API project. 4 | 5 | In my demo, this project will be served by NGINX using Docker Compose. In order to let NGINX have access to the compiled Angular web app, please run command `npm run deploy:nginx` to save the output files to a `wwwroot` folder in the `nginx` directory. 6 | 7 | Alternatively, you can deploy the Angular web app to a `wwwroot` folder in the ASP.NET Core web app and use Kestrel to serve the Angular app as a SPA. 8 | 9 | ## API BaseURL 10 | 11 | Currently, the API BaseURL is set in the `environment.ts` file with a value of `https://localhost:5001`. You can change it to an empty string if the app is served by Kestrel. Or you can modify the value according to the web API app. 12 | -------------------------------------------------------------------------------- /angular/src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
6 |

Hi {{ user.username }},

7 |
8 |
9 |
Your access token is
10 | {{ accessToken }} 11 |
12 |
13 |
Your refresh token is
14 | {{ refreshToken }} 15 |
16 |
17 |
18 |
19 | 20 |
21 |

You have been logged out

22 |
23 |
24 | Please click here to login. 25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /angular/src/app/demo-apis/demo-apis.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { environment } from '../../environments/environment'; 4 | import { finalize } from 'rxjs/operators'; 5 | 6 | @Component({ 7 | selector: 'app-demo-apis', 8 | templateUrl: './demo-apis.component.html', 9 | styleUrls: ['./demo-apis.component.css'], 10 | }) 11 | export class DemoApisComponent implements OnInit { 12 | private readonly apiUrl = `${environment.apiUrl}api/values`; 13 | busy = false; 14 | values: string[] = []; 15 | constructor(private http: HttpClient) {} 16 | 17 | ngOnInit(): void {} 18 | getValues() { 19 | this.busy = true; 20 | this.http 21 | .get(this.apiUrl) 22 | .pipe(finalize(() => (this.busy = false))) 23 | .subscribe((x) => { 24 | this.values = x; 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development" 9 | }, 10 | "private": true, 11 | "dependencies": { 12 | "@angular/animations": "^17.0.0", 13 | "@angular/common": "^17.0.0", 14 | "@angular/compiler": "^17.0.0", 15 | "@angular/core": "^17.0.0", 16 | "@angular/forms": "^17.0.0", 17 | "@angular/platform-browser": "^17.0.0", 18 | "@angular/platform-browser-dynamic": "^17.0.0", 19 | "@angular/router": "^17.0.0", 20 | "bootstrap": "^5.3.2", 21 | "rxjs": "~7.8.0", 22 | "tslib": "^2.3.0", 23 | "zone.js": "~0.14.2" 24 | }, 25 | "devDependencies": { 26 | "@angular-devkit/build-angular": "^17.0.7", 27 | "@angular/cli": "^17.0.7", 28 | "@angular/compiler-cli": "^17.0.0", 29 | "typescript": "~5.2.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Infrastructure/JwtRefreshTokenCache.cs: -------------------------------------------------------------------------------- 1 | namespace JwtAuthDemo.Infrastructure; 2 | 3 | public class JwtRefreshTokenCache(IJwtAuthManager jwtAuthManager) : IHostedService, IDisposable 4 | { 5 | private Timer _timer = null!; 6 | 7 | public Task StartAsync(CancellationToken stoppingToken) 8 | { 9 | // remove expired refresh tokens from cache every minute 10 | _timer = new Timer(DoWork!, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); 11 | return Task.CompletedTask; 12 | } 13 | 14 | private void DoWork(object state) 15 | { 16 | jwtAuthManager.RemoveExpiredRefreshTokens(DateTime.Now); 17 | } 18 | 19 | public Task StopAsync(CancellationToken stoppingToken) 20 | { 21 | _timer.Change(Timeout.Infinite, 0); 22 | return Task.CompletedTask; 23 | } 24 | 25 | public void Dispose() 26 | { 27 | _timer.Dispose(); 28 | GC.SuppressFinalize(this); 29 | } 30 | } -------------------------------------------------------------------------------- /angular/src/app/core/interceptors/jwt.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | HttpRequest, 4 | HttpHandler, 5 | HttpEvent, 6 | HttpInterceptor, 7 | } from '@angular/common/http'; 8 | import { Observable } from 'rxjs'; 9 | import { environment } from '../../../environments/environment'; 10 | 11 | @Injectable() 12 | export class JwtInterceptor implements HttpInterceptor { 13 | 14 | intercept( 15 | request: HttpRequest, 16 | next: HttpHandler 17 | ): Observable> { 18 | // add JWT auth header if a user is logged in for API requests 19 | const accessToken = localStorage.getItem('access_token'); 20 | const isApiUrl = request.url.startsWith(environment.apiUrl); 21 | if (accessToken && isApiUrl) { 22 | request = request.clone({ 23 | setHeaders: { Authorization: `Bearer ${accessToken}` }, 24 | }); 25 | } 26 | 27 | return next.handle(request); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo.IntegrationTests/TestHostFixture .cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.AspNetCore.TestHost; 3 | using Microsoft.Extensions.Hosting; 4 | 5 | namespace JwtAuthDemo.IntegrationTests; 6 | 7 | public class TestHostFixture : IDisposable 8 | { 9 | public HttpClient Client { get; } 10 | public IServiceProvider ServiceProvider { get; } 11 | 12 | public TestHostFixture() 13 | { 14 | var builder = Program.CreateHostBuilder([]) 15 | .ConfigureWebHost(webHost => 16 | { 17 | webHost.UseTestServer(); 18 | webHost.UseEnvironment("Test"); 19 | }); 20 | var host = builder.Start(); 21 | ServiceProvider = host.Services; 22 | Client = host.GetTestClient(); 23 | Console.WriteLine("TEST Host Started."); 24 | } 25 | 26 | public void Dispose() 27 | { 28 | Client.Dispose(); 29 | GC.SuppressFinalize(this); 30 | } 31 | } -------------------------------------------------------------------------------- /angular/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { HttpClientModule } from '@angular/common/http'; 5 | 6 | import { CoreModule } from './core/core.module'; 7 | 8 | import { AppRoutingModule } from './app-routing.module'; 9 | import { AppComponent } from './app.component'; 10 | import { LoginComponent } from './login/login.component'; 11 | import { HomeComponent } from './home/home.component'; 12 | import { DemoApisComponent } from './demo-apis/demo-apis.component'; 13 | 14 | @NgModule({ 15 | declarations: [ 16 | AppComponent, 17 | LoginComponent, 18 | HomeComponent, 19 | DemoApisComponent, 20 | ], 21 | imports: [ 22 | BrowserModule, 23 | FormsModule, 24 | HttpClientModule, 25 | CoreModule, 26 | AppRoutingModule, 27 | ], 28 | providers: [], 29 | bootstrap: [AppComponent], 30 | }) 31 | export class AppModule {} 32 | -------------------------------------------------------------------------------- /angular/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | 3 | worker_processes auto; 4 | 5 | events { worker_connections 1024; } 6 | 7 | http { 8 | include /etc/nginx/mime.types; 9 | include /etc/nginx/gzip.conf; 10 | limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s; 11 | server_tokens off; 12 | 13 | sendfile on; 14 | keepalive_timeout 29; # Adjust to the lowest possible value that makes sense for your use case. 15 | client_body_timeout 10; client_header_timeout 10; send_timeout 10; 16 | 17 | server { 18 | listen 80; 19 | server_name $hostname; 20 | root /usr/share/nginx/html; 21 | 22 | add_header X-Frame-Options DENY; 23 | add_header X-Content-Type-Options nosniff; 24 | add_header X-Frame-Options "SAMEORIGIN"; 25 | 26 | location / { 27 | try_files $uri $uri/ /index.html; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /angular/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import { authGuard } from './core'; 4 | import { HomeComponent } from './home/home.component'; 5 | import { LoginComponent } from './login/login.component'; 6 | import { DemoApisComponent } from './demo-apis/demo-apis.component'; 7 | 8 | const routes: Routes = [ 9 | { 10 | path: '', 11 | pathMatch: 'full', 12 | component: HomeComponent, 13 | canMatch: [authGuard], 14 | }, 15 | { path: 'login', component: LoginComponent }, 16 | { path: 'demo-apis', component: DemoApisComponent, canMatch: [authGuard] }, 17 | { 18 | path: 'management', 19 | loadChildren: () => 20 | import('./management/management.module').then((m) => m.ManagementModule), 21 | canMatch: [authGuard], 22 | }, 23 | { path: '**', redirectTo: '' }, 24 | ]; 25 | 26 | @NgModule({ 27 | imports: [RouterModule.forRoot(routes)], 28 | exports: [RouterModule], 29 | }) 30 | export class AppRoutingModule {} 31 | -------------------------------------------------------------------------------- /angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo.IntegrationTests/JwtAuthDemo.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Changhui Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /angular/src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, APP_INITIALIZER, Optional, SkipSelf } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 4 | import { AuthService } from './services/auth.service'; 5 | import { appInitializer } from './services/app-initializer'; 6 | import { JwtInterceptor } from './interceptors/jwt.interceptor'; 7 | import { UnauthorizedInterceptor } from './interceptors/unauthorized.interceptor'; 8 | 9 | @NgModule({ 10 | declarations: [], 11 | imports: [CommonModule], 12 | providers: [ 13 | { 14 | provide: APP_INITIALIZER, 15 | useFactory: appInitializer, 16 | multi: true, 17 | deps: [AuthService], 18 | }, 19 | { provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true }, 20 | { 21 | provide: HTTP_INTERCEPTORS, 22 | useClass: UnauthorizedInterceptor, 23 | multi: true, 24 | }, 25 | ], 26 | }) 27 | export class CoreModule { 28 | constructor(@Optional() @SkipSelf() core: CoreModule) { 29 | if (core) { 30 | throw new Error('Core Module can only be imported to AppModule.'); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace JwtAuthDemo.Controllers; 4 | 5 | [ApiController] 6 | [Route("[controller]")] 7 | public class WeatherForecastController(ILogger logger) : ControllerBase 8 | { 9 | private static readonly string[] Summaries = { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; 10 | 11 | [HttpGet] 12 | public IEnumerable Get() 13 | { 14 | var userName = User.Identity?.Name; 15 | logger.LogInformation("User [{userName}] is viewing weather forecast.", userName); 16 | var rng = new Random(); 17 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 18 | { 19 | Date = DateTime.Now.AddDays(index), 20 | TemperatureC = rng.Next(-20, 55), 21 | Summary = Summaries[rng.Next(Summaries.Length)] 22 | }) 23 | .ToArray(); 24 | } 25 | } 26 | 27 | public class WeatherForecast 28 | { 29 | public DateTime Date { get; set; } 30 | 31 | public int TemperatureC { get; set; } 32 | 33 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 34 | 35 | public string Summary { get; set; } = string.Empty; 36 | } -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Authentication; 2 | using Microsoft.AspNetCore.Server.Kestrel.Core; 3 | 4 | namespace JwtAuthDemo; 5 | 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder.ConfigureKestrel(serverOptions => 18 | { 19 | serverOptions.Limits.MinRequestBodyDataRate = new MinDataRate(100, TimeSpan.FromSeconds(10)); 20 | serverOptions.Limits.MinResponseDataRate = new MinDataRate(100, TimeSpan.FromSeconds(10)); 21 | serverOptions.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); 22 | serverOptions.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(1); 23 | serverOptions.ConfigureHttpsDefaults(listenOptions => 24 | { 25 | listenOptions.SslProtocols = SslProtocols.Tls12; 26 | }); 27 | }) 28 | .UseStartup(); 29 | }); 30 | } -------------------------------------------------------------------------------- /angular/src/app/core/interceptors/unauthorized.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | HttpRequest, 4 | HttpHandler, 5 | HttpEvent, 6 | HttpInterceptor, 7 | } from '@angular/common/http'; 8 | import { Observable } from 'rxjs'; 9 | import { catchError } from 'rxjs/operators'; 10 | import { AuthService } from '../services/auth.service'; 11 | import { environment } from '../../../environments/environment'; 12 | import { Router } from '@angular/router'; 13 | 14 | @Injectable() 15 | export class UnauthorizedInterceptor implements HttpInterceptor { 16 | constructor(private authService: AuthService, private router: Router) {} 17 | 18 | intercept( 19 | request: HttpRequest, 20 | next: HttpHandler 21 | ): Observable> { 22 | return next.handle(request).pipe( 23 | catchError((err) => { 24 | if (err.status === 401) { 25 | this.authService.clearLocalStorage(); 26 | this.router.navigate(['login'], { 27 | queryParams: { returnUrl: this.router.routerState.snapshot.url }, 28 | }); 29 | } 30 | 31 | if (!environment.production) { 32 | console.error(err); 33 | } 34 | const error = (err && err.error && err.error.message) || err.statusText; 35 | throw Error(error); 36 | }) 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /angular/src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 | logo 10 |

.NET Labs

11 |
12 | 13 | 60 | -------------------------------------------------------------------------------- /webapi/JwtAuthDemo/Services/UsersService.cs: -------------------------------------------------------------------------------- 1 | namespace JwtAuthDemo.Services; 2 | 3 | public interface IUserService 4 | { 5 | bool IsAnExistingUser(string userName); 6 | bool IsValidUserCredentials(string userName, string password); 7 | string GetUserRole(string userName); 8 | } 9 | 10 | public class UserService(ILogger logger) : IUserService 11 | { 12 | private readonly Dictionary _users = new() 13 | { 14 | { "test1", "password1" }, 15 | { "test2", "password2" }, 16 | { "admin", "securePassword" } 17 | }; 18 | // inject your database here for user validation 19 | 20 | public bool IsValidUserCredentials(string userName, string password) 21 | { 22 | logger.LogInformation("Validating user [{userName}]", userName); 23 | if (string.IsNullOrWhiteSpace(userName)) 24 | { 25 | return false; 26 | } 27 | 28 | if (string.IsNullOrWhiteSpace(password)) 29 | { 30 | return false; 31 | } 32 | 33 | return _users.TryGetValue(userName, out var p) && p == password; 34 | } 35 | 36 | public bool IsAnExistingUser(string userName) 37 | { 38 | return _users.ContainsKey(userName); 39 | } 40 | 41 | public string GetUserRole(string userName) 42 | { 43 | if (!IsAnExistingUser(userName)) 44 | { 45 | return string.Empty; 46 | } 47 | 48 | if (userName == "admin") 49 | { 50 | return UserRoles.Admin; 51 | } 52 | 53 | return UserRoles.BasicUser; 54 | } 55 | } 56 | 57 | public static class UserRoles 58 | { 59 | public const string Admin = nameof(Admin); 60 | public const string BasicUser = nameof(BasicUser); 61 | } -------------------------------------------------------------------------------- /angular/src/app/login/login.component.css: -------------------------------------------------------------------------------- 1 | :host{ 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: 100%; 7 | } 8 | 9 | .form-signin { 10 | width: 100%; 11 | max-width: 420px; 12 | padding: 15px; 13 | } 14 | 15 | .form-label-group { 16 | position: relative; 17 | margin-bottom: 1rem; 18 | } 19 | 20 | .form-label-group > input, 21 | .form-label-group > label { 22 | height: 3.125rem; 23 | padding: 0.75rem; 24 | } 25 | 26 | .form-label-group > label { 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | display: block; 31 | width: 100%; 32 | margin-bottom: 0; /* Override default `