- The authentication scheme name is stored in the cookie, so it must match between apps.
- With
AddAuthentication()
only the default scheme is used, regardless of how many schemes you register, unless you... - Customize
AddAuthorization()
to consider themes other than the default. See the docs - 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
- When using identity with identityserver replace
IdentityServerConstants.ExternalCookieAuthenticationScheme
withIdentityConstants.ExternalScheme
globally, because that's what identity uses. See the first note here
mkdir MyApp
cd MyApp
dotnet new -i IdentityServer4.Templates
dotnet new sln
dotnet new is4aspid -o Auth
dotnet new angular -o Web
dotnet sln add Auth
dotnet sln add Web
- edit
Web/Web.csproj
for server side building Web.csproj rd /s /q Web\ClientApp
ng new Web\ClientApp
- in
Web\ClientApp
: npm i -S oidc-client
ng add @nguniversal/express-engine
- modify
package.json
line 13 to"build:ssr": "ng build --prod && ng run ClientApp:server-aspnet:production",
- add src\silent-renew.html
- add
src\oidc-client.min.js
(copy fromnode_modules\oidc-client\dist
) - add tsconfig.server-aspnet.json
- modify angular.json:
- copy json block
architect:server
toarchitect:server-aspnet
- change
main
toserver-aspnet.ts
- change
tsconfig
totsconfig.server-aspnet.json
- add
src/silent-renew.html
andsrc/oidc-client.min.js
to assets
- copy json block
- enable state transfer
- add
ServerTransferStateModule
andBrowserTransferStateModule
toapp.server.module.ts
andapp.module.ts respectively
- add [src\app\backend-state.ts]
- modify
src\app\main.server.ts
line 9 toexport { AppServerModule, BackendState, BACKEND_STATE } from './app/app.server.module';
- add
- add these components and services
ng g s auth
auth.service.tsng g s notification
ng g c callback
- add proxy.conf.json
- modify
package.json
line 6 to"start": "ng serve --proxy-config proxy.conf.json",
- open
MyApp.sln
in visual studio - in project
Auth
- modify
Startup.cs
- replace
.AddInMemory...
with this code block - change the database provider from
Sqlite
to whatever - add
sql => sql.MigrationsAssembly(typeof(Startup).Assembly.FullName)
as 2nd parameter tobuilder.UseSql...
for both theAddConfigurationStore
and theAddOperationalStore
calls. rd /s /q Migrations
- if you want to add a prefix to the table names add this code snippet to the
AddConfigurationStore
andAddOperationalStore
calls dotnet ef migrations add Configuration --context ConfigurationDbContext -o Data\Migrations\Configuration
dotnet ef migrations add PersistedGrant --context PersistedGrantDbContext -o Data\Migrations\PersistedGrant
dotnet ef database update --context ConfigurationDbContext
dotnet ef database update --context PersistedGrantDbContext
- add data protection
- add to
Configure
this lineapp.UsePathBase("/auth");
before any other middleware
- replace
- add ef stores
dotnet add package IdentityServer4.EntityFramework
- optionally rename identity tables by modifying
ApplicationDbContext.OnModelCreating
dotnet ef migrations add Identity --context ApplicationDbContext -o Data\Migrations\Identity
dotnet ef database update --context ApplicationDbContext
- in
Startup.cs
replace.AddTestUsers(TestUsers.Users)
with.AddAspNetIdentity<IdentityUser>()
- modify user management
- 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 exampleRegister
.
- in visual studio right click the project and select
- modify
<!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>
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>
/* 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"
}
}
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]);
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: {
},
};
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);
});
}
}
{
"/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
}
}
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;
});
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;
});
services.AddDataProtection()
.PersistKeysTo...(...)
.SetApplicationName("SharedAppName");
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");
}