Quantcast
Channel: damienbod – Software Engineering
Viewing all articles
Browse latest Browse all 353

Angular2 OpenID Connect Implicit Flow with IdentityServer4

$
0
0

This article shows how to implement an OpenID Connect Implicit Flow client in Angular2. The Angular2 client is implemented in Typescript and uses IdentityServer4 and an ASP.NET core 1.0 resource server. The OpenID Connect specification for Implicit Flow can be found here.

Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow

IdentityServer4 Configuration

The client configuration in IdentityServer4 is set up to use the enum Flow.Implicit and the required Angular2 client URLs. The RedirectUris must match the redirect_uri URL used for the client authorization request.

new Client
{
	ClientName = "angular2client",
	ClientId = "angular2client",
	Flow = Flows.Implicit,
	RedirectUris = new List<string>
	{
		"https://localhost:44311"
	},
	PostLogoutRedirectUris = new List<string>
	{
		"https://localhost:44311/Unauthorized.html"
	},
	AllowedCorsOrigins = new List<string>
	{
		"https://localhost:44311",
		"http://localhost:44311"
	},
	AllowedScopes = new List<string>
	{
		"openid",
		"dataEventRecords",
		"role"
	}
}

Angular2 client using ASP.NET Core

Angular2 is downloaded from npm. The npm dependencies are defined in the package.json file. This file is hidden per default in Visual Studio. This can be made visible by adding DnxInvisibleContent Include=”package.json” to the project file. The required angular2 dependencies can be copied from the quickstart Angular2 guide on the Angular2 web page. This changes regularly.

{
    "version": "1.0.0",
    "description": "",
    "main": "wwwroot/index.html",
    "author": "",
    "license": "ISC",
    "scripts": {
        "tsc": "tsc",
        "tsc:w": "tsc -w",
        "typings": "typings",
        "postinstall": "typings install"
    },
    "dependencies": {
        "angular2": "2.0.0-beta.7",
        "systemjs": "0.19.23",
        "es6-promise": "3.1.2",
        "es6-shim": "0.35.0",
        "reflect-metadata": "0.1.3",
        "rxjs": "5.0.0-beta.2",
        "zone.js": "0.6.1",
        "bootstrap": "^3.3.6",
        "gulp": "^3.9.0"
    },
    "devDependencies": {
        "jquery": "^2.2.0",
        "typescript": "1.8.7",
        "typings": "0.6.8"
    }
}

You need to update your typings config file in your project then. You can add the following configuration to your file. Your could also do this in the commandline using npm. (npm install typings -g, typings init)

{
    "dependencies": { },
    "devDependencies": { },
    "ambientDependencies": {
        "es6-shim": "github:DefinitelyTyped/DefinitelyTyped/es6-shim/es6-shim.d.ts#6697d6f7dadbf5773cb40ecda35a76027e0783b2"
    }
}

gulp is then used to to copy the required frontend dependecies to the wwwroot/libs folder from the node modules download folder. This gulp task needs to be executed every time a new dependency is added or updated.

var gulp = require('gulp');

var paths = {
    npmSrc: "./node_modules/",
    libTarget: "./wwwroot/libs/"
};

var packagesToMove = [
   paths.npmSrc + '/angular2/bundles/angular2-polyfills.js',
   paths.npmSrc + '/angular2/bundles/router.dev.js',
   paths.npmSrc + '/angular2/bundles/http.dev.js',
   paths.npmSrc + '/angular2/bundles/angular2.dev.js',
   paths.npmSrc + '/angular2/es6/dev/src/testing/shims_for_IE.js',

   paths.npmSrc + '/systemjs/dist/system.js',
   paths.npmSrc + '/systemjs/dist/system-polyfills.js',

   paths.npmSrc + '/rxjs/bundles/Rx.js',
   paths.npmSrc + '/es6-shim/es6-shim.min.js',
   paths.npmSrc + '/reflect-metadata/Reflect.js',
   paths.npmSrc + '/zone.js/dist/zone.js',
   paths.npmSrc + '/jquery/dist/jquery.min.js',
   paths.npmSrc + '/angular2-localstorage/LocalStorage.ts',
   paths.npmSrc + '/angular2-localstorage/LocalStorageEmitter.ts'
   
];
gulp.task('copyNpmTo_wwwrootLibs', function () {
    return gulp.src(packagesToMove).pipe(gulp.dest(paths.libTarget));
});

The Typescript configuration for the project is defined in the tsconfig.json file in the root of the project. This is required to produce the js files.

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "removeComments": true,
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true,
        "noEmitHelpers": false,
        "sourceMap": true
    },
    "exclude": [
        "node_modules",
        "typings/main",
        "typings/main.d.ts"
    ],
    "compileOnSave": false,
    "buildOnSave": false
}

The Angular2 application is then initialized in the index.html file in the wwwroot folder. The required dependencies for Angular2 are added manually as script links. This could be done better for example using JSPM, but Visual Studio doesn’t work properly together with JSPM.

<!DOCTYPE html>
<html>

<head>
    <base href="/" />
    <title>ASP.NET Core 1.0 Angular 2 IdentityServer4 Client</title>

    <!-- inject:css -->
    <link rel="stylesheet" href="css/bootstrap.css">
    <!-- endinject -->
</head>

<body>
    <my-app>Loading...</my-app>

    <!-- 1. Load libraries -->
    <!-- IE required polyfills, in this exact order -->
    <script src="libs/es6-shim.min.js"></script>
    <script src="libs/shims_for_ie.js"></script>
    <script src="libs/system-polyfills.js"></script>
    <script src="libs/angular2-polyfills.js"></script>
    <script src="libs/system.js"></script>
    <script src="libs/Rx.js"></script>
    <script src="libs/angular2.dev.js"></script>
    <script src="libs/jquery.min.js"></script>
    <script src="js/bootstrap.js"></script>
    <script src="libs/http.dev.js"></script>
    <script src="libs/router.dev.js"></script>

    <!-- 2. Configure SystemJS -->
    <script>
        System.config({
            packages: {
                app: {
                    defaultExtension: 'js'
                }
            }
        });
        System.import('app/boot')
            .then(null, console.error.bind(console));
    </script>
</body>
</html>

To make the angular2 application work with a F5 refresh in the browser, middleware needs to be added to the Startup class.

public void Configure(IApplicationBuilder app)
{
	app.UseIISPlatformHandler();

	var angularRoutes = new[] {
		"/home",
		"/overviewindex",
		"/create",
		"/forbidden",
		"/details",
		"/authorized",
		"/authorize",
		"/unauthorized",
		"/logoff",
	};

	app.Use(async (context, next) =>
	{
		if (context.Request.Path.HasValue &&
			null !=
			angularRoutes.FirstOrDefault(
			(ar) => context.Request.Path.Value.StartsWith(ar, StringComparison.OrdinalIgnoreCase)))
		{
			context.Request.Path = new PathString("/");
		}

		await next();
	});

	app.UseDefaultFiles();
	app.UseStaticFiles();

	app.Run(async (context) =>
	{
		await context.Response.WriteAsync("Hello World!");
	});
}

Angular2 Authorize

The authorization process is initialized in the AppComponent. The html defines the Login and the Logout buttons. The buttons are displayed using the securityService.IsAuthorized which is set using the securityService authorize process. the (click) definition is used to define the click event in Angular2.

<div class="container" style="margin-top: 15px;">
    <!-- Static navbar -->
    <nav class="navbar navbar-default">
        <div class="container-fluid">
            <div class="navbar-header">
                <button aria-controls="navbar" aria-expanded="false" data-target="#navbar" data-toggle="collapse" class="navbar-toggle collapsed" type="button">
                    <span class="sr-only">Toggle navigation</span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a [routerLink]="['/Overviewindex']" class="navbar-brand"><img src="images/damienbod.jpg" height="40" style="margin-top:-10px;" /></a>
            </div>
            <div class="navbar-collapse collapse" id="navbar">
                <ul class="nav navbar-nav">
                    <li><a [routerLink]="['/Overviewindex']">Overviewindex</a></li>
                    <li><a [routerLink]="['/Create']">Create</a></li>

                    <li><a class="navigationLinkButton" *ngIf="!securityService.IsAuthorized" (click)="Login()">Login</a></li>
                    <li><a class="navigationLinkButton" *ngIf="securityService.IsAuthorized" (click)="Logout()">Logout</a></li>
              
                </ul>
            </div><!--/.nav-collapse -->
        </div><!--/.container-fluid -->
    </nav>

    <router-outlet></router-outlet>

</div>

The app.component.ts defines the routes and the Login, Logout click events. The component uses the @Injectable() SecurityService where the authorization is implemented. It is important that the authorization callback is used outside of the angular routing as this removes the hash which is used to return the id_token. The ngOnInit method just checks if a hash exists, and if it does, executes the AuthorizedCallback method in the SecurityService.

import {Component} from 'angular2/core';
import {RouteConfig, ROUTER_DIRECTIVES} from 'angular2/router';
import {OverviewindexComponent} from './overviewindex/overviewindex.component';
import {CreateComponent} from './create/create.component';
import {ForbiddenComponent} from './forbidden/forbidden.component';
import {UnauthorizedComponent} from './unauthorized/unauthorized.component';
import {DetailsComponent} from './details/details.component';
import {SecurityService} from './services/SecurityService';

@Component({
    selector: 'my-app',
    templateUrl: 'app/app.component.html',
    directives: [ROUTER_DIRECTIVES],
    styleUrls: ['app/app.component.css']
})

@RouteConfig([
        { path: '/Create', name: 'Create', component: CreateComponent },
        { path: '/Overviewindex', name: 'Overviewindex', component: OverviewindexComponent },
        { path: '/Forbidden', name: 'Forbidden', component: ForbiddenComponent },
        { path: '/Unauthorized', name: 'Unauthorized', component: UnauthorizedComponent },
        { path: '/Details/:Id', name: 'Details', component: DetailsComponent }
])
 
export class AppComponent {

    constructor(public securityService: SecurityService) {     
    }

    ngOnInit() {
        console.log("ngOnInit _securityService.AuthorizedCallback");

        if (window.location.hash) {
            this.securityService.AuthorizedCallback();
        }      
    }

    public Login() {
        console.log("Do login logic");
        this.securityService.Authorize(); 
    }

    public Logout() {
        console.log("Do logout logic");
        this.securityService.Logoff();
    }
}

The Authorize method calls the IdentityServer4 connect/authorize using a response type “id_token token”. This is one of the OpenID Connect Implicit flow which is described in the OpenID specification. The required parameters are also defined in this specification. Important is that the used parameters match the IdentityServer4 client definition.

public Authorize() {
	this.ResetAuthorizationData();

	console.log("BEGIN Authorize, no auth data");

	var authorizationUrl = 'https://localhost:44345/connect/authorize';
	var client_id = 'angular2client';
	var redirect_uri = 'https://localhost:44311';
	var response_type = "id_token token";
	var scope = "dataEventRecords aReallyCoolScope openid";
	var nonce = "N" + Math.random() + "" + Date.now();
	var state = Date.now() + "" + Math.random();

	this.store("authStateControl", state);
	this.store("authNonce", nonce);
	console.log("AuthorizedController created. adding myautostate: " + this.retrieve("authStateControl"));

	var url =
		authorizationUrl + "?" +
		"response_type=" + encodeURI(response_type) + "&" +
		"client_id=" + encodeURI(client_id) + "&" +
		"redirect_uri=" + encodeURI(redirect_uri) + "&" +
		"scope=" + encodeURI(scope) + "&" +
		"nonce=" + encodeURI(nonce) + "&" +
		"state=" + encodeURI(state);

	window.location.href = url;
}

The user is redirected to the default IdentityServer4 login html view:
angular2_IdentityServer4_01

And then to the permissions page, which shows what the client is requesting.
angular2_IdentityServer4_02

Angular2 Authorize Callback

The AuthorizedCallback uses the returned hash to extract the token and the id_token and save these to the local storage of the browser. The method also checks the state and the nonce to prevent cross-site request forgery attacks.

public AuthorizedCallback() {
	console.log("BEGIN AuthorizedCallback, no auth data");
	this.ResetAuthorizationData();

	var hash = window.location.hash.substr(1);

	var result: any = hash.split('&').reduce(function (result, item) {
		var parts = item.split('=');
		result[parts[0]] = parts[1];
		return result;
	}, {});

	console.log(result);
	console.log("AuthorizedCallback created, begin token validation");

	var token = "";
	var id_token = "";
	var authResponseIsValid = false;
	if (!result.error) {

		if (result.state !== this.retrieve("authStateControl")) {
			console.log("AuthorizedCallback incorrect state");
		} else {

			token = result.access_token;
			id_token = result.id_token

			var dataIdToken: any = this.getDataFromToken(id_token);
			console.log(dataIdToken);

			// validate nonce
			if (dataIdToken.nonce !== this.retrieve("authNonce")) {
				console.log("AuthorizedCallback incorrect nonce");
			} else {
				this.store("authNonce", "");
				this.store("authStateControl", "");

				authResponseIsValid = true;
				console.log("AuthorizedCallback state and nonce validated, returning access token");
			}
		}
	}

	if (authResponseIsValid) {
		this.SetAuthorizationData(token, id_token);
		console.log(this.retrieve("authorizationData"));

		this._router.navigate(['Overviewindex']);
	}
	else {
		this.ResetAuthorizationData();
		this._router.navigate(['Unauthorized']);
	}
}

Setting and reset the Authorization Data in the client

The received tokens and authorization data are saved or removed to the local storage and also the possible roles for the user. The application has only an ‘dataEventRecords.admin’ role or not. This matches the policy defined on the resource server.

public ResetAuthorizationData() {
	this.store("authorizationData", "");
	this.store("authorizationDataIdToken", "");

	this.IsAuthorized = false;
	this.HasAdminRole = false;
	this.store("HasAdminRole", false);
	this.store("IsAuthorized", false);
}

public SetAuthorizationData(token: any, id_token:any) {
	if (this.retrieve("authorizationData") !== "") {
		this.store("authorizationData", "");
	}

	this.store("authorizationData", token);
	this.store("authorizationDataIdToken", id_token);
	this.IsAuthorized = true;
	this.store("IsAuthorized", true);

	var data: any = this.getDataFromToken(token);
	for (var i = 0; i < data.role.length; i++) {
		if (data.role[i] === "dataEventRecords.admin") {
			this.HasAdminRole = true;
			this.store("HasAdminRole", true)
		}
	}
}

The private retrieve and store methods are just used to store the data or get it from the local storage in the browser.

private retrieve(key: string): any {
	var item = this.storage.getItem(key);

	if (item && item !== 'undefined') {
		return JSON.parse(this.storage.getItem(key));
	}

	return;
}

private store(key: string, value: any) {
	this.storage.setItem(key, JSON.stringify(value));
}

Using the token to access the data

Now that the local storage has a token, this can then be used and added to the HTTP request headers. This is implemented in the DataEventRecordsService class. The setHeaders method is used to add the Authorization header with the token. Each request to the resource server uses this then.

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import 'rxjs/add/operator/map'
import { Observable } from 'rxjs/Observable';
import { Configuration } from '../app.constants';
import { SecurityService } from '../services/SecurityService';
import { DataEventRecord } from '../models/DataEventRecord';

@Injectable()
export class DataEventRecordsService {

    private actionUrl: string;
    private headers: Headers;

    constructor(private _http: Http, private _configuration: Configuration, private _securityService: SecurityService) {
        this.actionUrl = _configuration.Server + 'api/DataEventRecords/';   
    }

    private setHeaders() {
        this.headers = new Headers();
        this.headers.append('Content-Type', 'application/json');
        this.headers.append('Accept', 'application/json');

        var token = this._securityService.GetToken();

        if (token !== "") {
            this.headers.append('Authorization', 'Bearer ' + token);
        }
    }

    public GetAll = (): Observable<DataEventRecord[]> => {
        this.setHeaders();
        return this._http.get(this.actionUrl, {
            headers: this.headers
        }).map(res => res.json());
    }

    public GetById = (id: number): Observable<DataEventRecord> => {
        this.setHeaders();
        return this._http.get(this.actionUrl + id, {
            headers: this.headers
        }).map(res => res.json());
    }

    public Add = (itemToAdd: any): Observable<Response> => {       
        this.setHeaders();
        return this._http.post(this.actionUrl, JSON.stringify(itemToAdd), { headers: this.headers });
    }

    public Update = (id: number, itemToUpdate: any): Observable<Response> => {
        this.setHeaders();
        return this._http
            .put(this.actionUrl + id, JSON.stringify(itemToUpdate), { headers: this.headers });
    }

    public Delete = (id: number): Observable<Response> => {
        this.setHeaders();
        return this._http.delete(this.actionUrl + id, {
            headers: this.headers
        });
    }

}

The token is then used to request the resource data and displays the secured data in the client application.

angular2_IdentityServer4_03

Using the roles, IsAuthorized check

The securityService.HasAdminRole is used to remove links and replace them with texts if the logged-in user has no claims to execute an edit entity. The delete button should also be disabled using this property, but this is left active to display the forbidden redirect with a 403.

<div class="col-md-12" *ngIf="securityService.IsAuthorized" >
    <div class="panel panel-default">
        <div class="panel-heading">
            <h3 class="panel-title">{{message}}</h3>
        </div>
        <div class="panel-body">
            <table class="table">
                <thead>
                    <tr>
                        <th>Name</th>
                        <th>Timestamp</th>
                    </tr>
                </thead>
                <tbody>
                    <tr style="height:20px;" *ngFor="#dataEventRecord of DataEventRecords" >
                        <td>
                            <a *ngIf="securityService.HasAdminRole" href="/Details/{{dataEventRecord.Id}}">{{dataEventRecord.Name}}</a>
                            <span *ngIf="!securityService.HasAdminRole">{{dataEventRecord.Name}}</span>
                        </td>
                        <td>{{dataEventRecord.Timestamp}}</td>
                        <td><button (click)="Delete(dataEventRecord.Id)">Delete</button></td>
                    </tr>
                </tbody>
            </table>

        </div>
    </div>
</div>

The private getData method uses the DataEventRecordsService to get the secured data. The SecurityService service is used to handle the errors from a server HTTP request to the resource server.

import { Component, OnInit } from 'angular2/core';
import { CORE_DIRECTIVES } from 'angular2/common';
import { DataEventRecordsService } from '../services/DataEventRecordsService';
import { SecurityService } from '../services/SecurityService';
import { Observable }       from 'rxjs/Observable';
import { Router } from 'angular2/router';
import { DataEventRecord } from '../models/DataEventRecord';

@Component({
    selector: 'overviewindex',
    templateUrl: 'app/overviewindex/overviewindex.component.html',
    directives: [CORE_DIRECTIVES],
    providers: [DataEventRecordsService]
})

export class OverviewindexComponent implements OnInit {

    public message: string;
    public DataEventRecords: DataEventRecord[];
   
    constructor(private _dataEventRecordsService: DataEventRecordsService, public securityService: SecurityService, private _router: Router) {
        this.message = "Overview DataEventRecords";

    }

    ngOnInit() {
        this.getData();
    }

    public Delete(id: any) {
        console.log("Try to delete" + id);
        this._dataEventRecordsService.Delete(id)
            .subscribe((() => console.log("subscribed")),
            error => this.securityService.HandleError(error),
            () => this.getData());
    }

    private getData() {
        this._dataEventRecordsService
            .GetAll()
            .subscribe(data => this.DataEventRecords = data,
            error => this.securityService.HandleError(error),
            () => console.log('Get all completed'));
    }

}


Forbidden, Handle 401, 403 errors

The HandleError method in the SecurityService is used to check for a 401, 403 status in the response. If a 403 is returned, the user is redirected to the forbidden page. If a 401 is returned, the local user is reset and redirected to the Unauthorized route.

public HandleError(error: any) {
        console.log(error);
        if (error.status == 403) {
            this._router.navigate(['Forbidden'])
        }
        else if (error.status == 401) {
            this.ResetAuthorizationData();
            this._router.navigate(['Unauthorized'])
        }
    }

The redirect in the UI.

angular2_IdentityServer4_04

With Angular2 it’s not as simple to implement cross cutting concerns like authorization data for a logged in user. In angular, this was better as you could just use the $rootscope. It is also more difficult to debug, have still to figure out how to debug in visual studio with breakpoints.

Links

http://openid.net/specs/openid-connect-core-1_0.html

http://openid.net/specs/openid-connect-implicit-1_0.html

Announcing IdentityServer for ASP.NET 5 and .NET Core

https://github.com/IdentityServer/IdentityServer4

https://github.com/IdentityServer/IdentityServer4.Samples

The State of Security in ASP.NET 5 and MVC 6: OAuth 2.0, OpenID Connect and IdentityServer

http://connect2id.com/learn/openid-connect

https://github.com/FabianGosebrink/Angular2-ASPNETCore-SignalR-Demo

Getting Started with ASP NET Core 1 and Angular 2 in Visual Studio 2015

http://benjii.me/2016/01/angular2-routing-with-asp-net-core-1/

http://tattoocoder.azurewebsites.net/angular2-aspnet5-spa-template/

Cross-platform Single Page Applications with ASP.NET Core 1.0, Angular 2 & TypeScript



Viewing all articles
Browse latest Browse all 353

Trending Articles