Skip to content

Instantly share code, notes, and snippets.

@paviad
Last active September 6, 2020 10:13
Show Gist options
  • Save paviad/e350713958d54450e4fe61e574dd215b to your computer and use it in GitHub Desktop.
Save paviad/e350713958d54450e4fe61e574dd215b to your computer and use it in GitHub Desktop.
Angular, SSR, IdentityServer4, Identity and Web Api tips

Web + Auth + Api using IdentityServer4

Important Notes

  1. The authentication scheme name is stored in the cookie, so it must match between apps.
  2. With AddAuthentication() only the default scheme is used, regardless of how many schemes you register, unless you...
  3. Customize AddAuthorization() to consider themes other than the default. See the docs
  4. You can also use a forward selector in the default scheme to determine if another scheme needs to be used (but that's not usually the case), see this
  5. When using identity with identityserver replace IdentityServerConstants.ExternalCookieAuthenticationScheme with IdentityConstants.ExternalScheme globally, because that's what identity uses. See the first note here

Checklist

  1. mkdir MyApp
  2. cd MyApp
  3. dotnet new -i IdentityServer4.Templates
  4. dotnet new sln
  5. dotnet new is4aspid -o Auth
  6. dotnet new angular -o Web
  7. dotnet sln add Auth
  8. dotnet sln add Web
  9. edit Web/Web.csproj for server side building Web.csproj
  10. rd /s /q Web\ClientApp
  11. ng new Web\ClientApp
  12. in Web\ClientApp:
  13. npm i -S oidc-client
  14. ng add @nguniversal/express-engine
  15. modify package.json line 13 to "build:ssr": "ng build --prod && ng run ClientApp:server-aspnet:production",
  16. add src\silent-renew.html
  17. add src\oidc-client.min.js (copy from node_modules\oidc-client\dist)
  18. add tsconfig.server-aspnet.json
  19. modify angular.json:
    1. copy json block architect:server to architect:server-aspnet
    2. change main to server-aspnet.ts
    3. change tsconfig to tsconfig.server-aspnet.json
    4. add src/silent-renew.html and src/oidc-client.min.js to assets
  20. enable state transfer
    1. add ServerTransferStateModule and BrowserTransferStateModule to app.server.module.ts and app.module.ts respectively
    2. add [src\app\backend-state.ts]
    3. modify src\app\main.server.ts line 9 to export { AppServerModule, BackendState, BACKEND_STATE } from './app/app.server.module';
  21. add these components and services
    1. ng g s auth auth.service.ts
    2. ng g s notification
    3. ng g c callback
  22. add proxy.conf.json
  23. modify package.json line 6 to "start": "ng serve --proxy-config proxy.conf.json",
  24. open MyApp.sln in visual studio
  25. in project Auth
    1. modify Startup.cs
      1. replace .AddInMemory... with this code block
      2. change the database provider from Sqlite to whatever
      3. add sql => sql.MigrationsAssembly(typeof(Startup).Assembly.FullName) as 2nd parameter to builder.UseSql... for both the AddConfigurationStore and the AddOperationalStore calls.
      4. rd /s /q Migrations
      5. if you want to add a prefix to the table names add this code snippet to the AddConfigurationStore and AddOperationalStore calls
      6. dotnet ef migrations add Configuration --context ConfigurationDbContext -o Data\Migrations\Configuration
      7. dotnet ef migrations add PersistedGrant --context PersistedGrantDbContext -o Data\Migrations\PersistedGrant
      8. dotnet ef database update --context ConfigurationDbContext
      9. dotnet ef database update --context PersistedGrantDbContext
      10. add data protection
      11. add to Configure this line app.UsePathBase("/auth"); before any other middleware
    2. add ef stores
      1. dotnet add package IdentityServer4.EntityFramework
      2. optionally rename identity tables by modifying ApplicationDbContext.OnModelCreating
      3. dotnet ef migrations add Identity --context ApplicationDbContext -o Data\Migrations\Identity
      4. dotnet ef database update --context ApplicationDbContext
      5. in Startup.cs replace .AddTestUsers(TestUsers.Users) with .AddAspNetIdentity<IdentityUser>()
    3. modify user management
      1. in visual studio right click the project and select Add --> New Scaffolded Item..., select the db context from the drop down, and select any page you wish to customize, for example Register.

silent-renew.html

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script src="oidc-client.min.js"></script>
    <script>
        console.debug('silent renew', window.location.href);
        const silentRenew$ = window['silentRenew$'] || (window.parent && window.parent['silentRenew$']);
        new Oidc.UserManager().signinSilentCallback()
            .catch((err) => {
                console.log(err);
            });
        if (silentRenew$) {
            console.debug('silent renew notifying');
            silentRenew$.next(true);
        } else {
            console.debug('silent renew NOT notifying');
        }
    </script>
</body>

</html>

Web.csproj

At or around line 12:

    <BuildServerSideRenderer>true</BuildServerSideRenderer>

At or around line 19:

  <ItemGroup>
    <!-- Don't publish the SPA source files, but do show them in the project files list -->
    <Compile Remove="tmp\**" />
    <Content Remove="$(SpaRoot)**" />
    <Content Remove="tmp\**" />
    <EmbeddedResource Remove="tmp\**" />
    <None Remove="$(SpaRoot)**" />
    <None Remove="tmp\**" />
    <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
  </ItemGroup>

At or around line 40:

  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --prod" Condition=" '$(BuildServerSideRenderer)' == 'false' " />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build:ssr" Condition=" '$(BuildServerSideRenderer)' == 'true' " />
    <Exec WorkingDirectory="tmp\" Command="npm i request ws eventsource" Condition=" '$(BuildServerSideRenderer)' == 'true' " />
    <ItemGroup>
      <CopyItems Include="$(SpaRoot)dist\**" />
    </ItemGroup>
    <Copy SourceFiles="@(CopyItems)" DestinationFolder="tmp\dist\%(RecursiveDir)" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup>
      <DistFiles Include="$(SpaRoot)dist\**; $(SpaRoot)dist-server\**" />
      <DistFiles Include="tmp\**" Condition="'$(BuildServerSideRenderer)' == 'true'" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>%(DistFiles.Identity)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>

tsconfig.server-aspnet.json

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/server",
    "target": "es2016",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts",
    "server-aspnet.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}

server-aspnet.ts

import 'zone.js/dist/zone-node';

import { join } from 'path';

import { AppServerModule, renderModule, BackendState, BACKEND_STATE } from './src/main.server';
import { readFileSync } from 'fs';

function app(url: string, stateJson: string) {
  const distFolder = join(process.cwd(), 'dist/ClientApp/browser');
  const indexHtml = join(distFolder, 'index.html');
  const document = readFileSync(indexHtml, 'utf8');
  let backendState: BackendState = null;
  try {
    backendState = JSON.parse(stateJson);
  }
  catch {
    // ignored
  }
  renderModule(AppServerModule, {
    url,
    document,
    extraProviders: [
      { provide: BACKEND_STATE, useValue: backendState }
    ]
  }).then(x => console.log(x));
}

app(process.argv[2], process.argv[3]);

backend-state.ts

import { InjectionToken, Injectable, Inject } from '@angular/core';
import { makeStateKey } from '@angular/platform-browser';

@Injectable()
export class BackendState {
    mobile: boolean;
    deployment: {
        web: string;
        authority: string;
        api: string;
    };
    antiForgeryToken: {
        requestToken?: string;
        formFieldName?: string;
        headerName?: string;
        cookieToken?: string;
    };
}

export const BACKEND_STATE = new InjectionToken<BackendState>('backendState');

export const BACKEND_STATE_KEY = makeStateKey('backendState');

export const DefaultBackendState: BackendState = {
    mobile: false,
    deployment: {
        web: 'http://localhost:4200/',
        authority: 'https://localhost:5001/auth',
        api: 'https://localhost:5003/api/'
    },
    antiForgeryToken: {
    },
};

auth.service.ts

import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { UserManager, User, UserManagerSettings } from 'oidc-client';
import { from, Observable, ReplaySubject, Subject, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
import { isPlatformServer } from '@angular/common';
import { Router } from '@angular/router';
import { BackendState } from './backend-state';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  config: UserManagerSettings = {
    authority: this.backendState.deployment.authority,
    client_id: 'interactive',
    client_secret: '49C1A7E1-0C79-4A89-A3D6-A37998FB86B0',
    redirect_uri: `${this.backendState.deployment.web}callback`,
    response_type: "code",
    scope: "openid profile email api",
    post_logout_redirect_uri: `${this.backendState.deployment.web}`,
    loadUserInfo: true,
    automaticSilentRenew: true,
    silent_redirect_uri: `${this.backendState.deployment.web}silent-renew.html`,
  };

  private mgr: UserManager;
  private pUser = new ReplaySubject<User>(1);
  user$: Observable<User> = this.pUser.asObservable();
  silentRenew$ = new Subject<boolean>();

  constructor(
    private http: HttpClient,
    private backendState: BackendState, 
    @Inject(PLATFORM_ID) private platformId, 
    private router: Router) {

    if (isPlatformServer(platformId)) {
      return;
    }
    this.mgr = new UserManager(this.config);
    this.signinSilent();
    this.refreshUser();
    window['silentRenew$'] = this.silentRenew$;
    this.silentRenew$.subscribe(r => {
      this.refreshUser();
    });
  }

  getUser() {
    if(isPlatformServer(this.platformId)){
      return of(null);
    }
    return from(this.mgr.getUser());
  }

  loginGoogle(selectAccount = false) {
    this.mgr.signinRedirect({
      acr_values: `idp:Google${selectAccount ? ' prompt:select_account' : ''}`,
    });
  }

  login() {
    this.mgr.signinRedirect({ state: window.location.pathname });
  }

  logout() {
    this.mgr.signoutRedirect();
  }

  refreshUser() {
    this.getUser().subscribe(r => {
      return this.pUser.next(r);
    });
  }

  signinSilent() {
    this.mgr.signinSilent().then(r => this.pUser.next(r));
  }

  callback() {
    if (isPlatformServer(this.platformId)) {
      return;
    }
    this.mgr.signinRedirectCallback().then(user => {
      this.refreshUser();
      this.router.navigateByUrl(user.state);
    }).catch(function (e) {
      console.error(e);
    });
  }
}

proxy.conf.json

{
  "/api": {
    "target": "https://localhost:5003",
    "secure": false,
    "changeOrigin": true,
    "logLevel": "debug"
  },
  "/auth": {
    "target": "https://localhost:5001",
    "secure": false,
    "changeOrigin": true,
    "logLevel": "debug"
  },
  "/api/ws": {
    "target": "https://localhost:5003",
    "secure": false,
    "changeOrigin": true,
    "logLevel": "debug",
    "ws": true
  }
}

Add Framework Stores

First replace this:

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")));

With this:

var connectionString = Configuration.GetConnectionString("DefaultConnection");

services.AddDbContext<ApplicationDbContext>(options => {
    options.UseSqlServer(connectionString,
        sql => sql.MigrationsAssembly(typeof(Startup).Assembly.FullName));
});

Then replace the .AddInMemory... part with this:

.AddConfigurationStore<ConfigurationDbContext>(options => {
    options.ConfigureDbContext = builder => builder.UseSqlServer(connectionString, 
        sql => sql.MigrationsAssembly(typeof(Startup).Assembly.FullName));
    var tableConfigs = options.GetType().GetProperties().Where(x => x.PropertyType == typeof(TableConfiguration)).ToList();
    tableConfigs.ForEach(x => {
        var cv = (TableConfiguration)x.GetValue(options);
        cv.Name = "Is4_" + cv.Name;
    });
})
// this adds the operational data from DB (codes, tokens, consents)
.AddOperationalStore<PersistedGrantDbContext>(options => {
    options.ConfigureDbContext = builder => builder.UseSqlServer(connectionString,
        sql => sql.MigrationsAssembly(typeof(Startup).Assembly.FullName));
    var tableConfigs = options.GetType().GetProperties().Where(x => x.PropertyType == typeof(TableConfiguration)).ToList();
    tableConfigs.ForEach(x => {
        var cv = (TableConfiguration)x.GetValue(options);
        cv.Name = "Is4_" + cv.Name;
    });

    // this enables automatic token cleanup. this is optional.
    options.EnableTokenCleanup = true;
});

Renaming tables

var tableConfigs = options.GetType().GetProperties().Where(x => x.PropertyType == typeof(TableConfiguration)).ToList();
tableConfigs.ForEach(x => {
    var cv = (TableConfiguration)x.GetValue(options);
    cv.Name = "Is4_" + cv.Name;
});

Data Protection

services.AddDataProtection()
    .PersistKeysTo...(...)
    .SetApplicationName("SharedAppName");

Rename Identity Tables

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);
    // Customize the ASP.NET Identity model and override the defaults if needed.
    // For example, you can rename the ASP.NET Identity table names and more.
    // Add your customizations after calling base.OnModelCreating(builder);

    builder.Entity<IdentityRoleClaim<string>>().ToTable("Is4_AspNetRoleClaims");
    builder.Entity<IdentityUserClaim<string>>().ToTable("Is4_AspNetUserClaims");
    builder.Entity<IdentityUserLogin<string>>().ToTable("Is4_AspNetUserLogins");
    builder.Entity<IdentityUserRole<string>>().ToTable("Is4_AspNetUserRoles");
    builder.Entity<IdentityUserToken<string>>().ToTable("Is4_AspNetUserTokens");
    builder.Entity<IdentityRole>().ToTable("Is4_AspNetRoles");
    builder.Entity<ApplicationUser>().ToTable("Is4_AspNetUsers");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment