diff --git a/README.md b/README.md index 1cfc522..a5b1e93 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,28 @@ #Windows Azure Active Directory Sample REST API Service for Node.js using MongoDB and Restify +<<<<<<< HEAD +This Node.js server will give you with a quick and easy way to set up a REST API Service that's integrated with Azure Active Directory for API protection. It uses the OAuth2 protocol with bearer tokens. The sample server included in the download are designed to run on any platform. +======= [![Join the chat at https://gitter.im/AzureADSamples/WebAPI-Nodejs](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/AzureADSamples/WebAPI-Nodejs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) This Node.js server will give you with a quick and easy way to set up a REST API Service that's integrated with Windows Azure Active Directory for API protection using the OAuth2 protocol. The sample server included in the download are designed to run on any platform. +>>>>>>> master This REST API server is built using Restify and MongoDB with the following features: -* A node.js server running an REST API interface with JSON using MongoDB as persistant storage +* A node.js server running an REST API interface with JSON using MongoDB as persistent storage * REST APIs leveraging OAuth2 API protection for endpoints using Windows Azure Active Directory -[Refer to our Wiki](https://github.com/AzureADSamples/WebAPI-Nodejs/wiki) for detailed walkthroughs on how to use this server. - We've released all of the source code for this example in GitHub under an Apache 2.0 license, so feel free to clone (or even better, fork!) and provide feedback on the forums. -## How to Use The Service - -This is a simple TODO Server that takes requests through GET and POST and responds with the appropriate JSON objects. - -#### To use this without Authentication (for testing the endpoints without Authentication) - - $ node server.js - - $ curl -isS http://127.0.0.1:8888 | json - HTTP/1.1 200 OK - Connection: close - Content-Type: application/x-www-form-urlencoded - Content-Length: 145 - Date: Wed, 29 Jan 2014 03:41:24 GMT - - [ - "GET /", - "POST /tasks/:name/:task", - "GET /tasks", - "DELETE /tasks", - "PUT /tasks/:name", - "GET /tasks/:name", - "DELETE /tasks/:task" - ] - -#### To invoke with OAuth2 Authentication (for use with Windows Azure AD) - - $ node server.js -m oauth2 ## Quick Start -Getting started with the sample is easy. It is configured to run out of the box with minimal setup. +Getting started with the sample is easy. It is configured to run out of the box with minimal setup. ### Step 1: Register a Windows Azure AD Tenant -To use this sample you will need a Windows Azure Active Directory Tenant. If you're not sure what a tenant is or how you would get one, read [What is a Windows Azure AD tenant](http://technet.microsoft.com/library/jj573650.aspx)? or [Sign up for Windows Azure as an organization](http://azure.microsoft.com/en-us/documentation/articles/sign-up-organization/). These docs should get you started on your way to using Windows Azure AD. +To use this sample you will need a Windows Azure Active Directory Tenant. If you're not sure what a tenant is or how you would get one, read [What is an Azure AD tenant](http://technet.microsoft.com/library/jj573650.aspx)? or [Sign up for Azure as an organization](http://azure.microsoft.com/en-us/documentation/articles/sign-up-organization/). These docs should get you started on your way to using Windows Azure AD. ### Step 2: Register your Web API with your Windows Azure AD Tenant @@ -57,16 +31,16 @@ After you get your Windows Azure AD tenant, add this sample app to your tenant s ### Step 3: Download node.js for your platform To successfully use this sample, you need a working installation of Node.js. -Install Node.js from [http://nodejs.org](http://nodejs.org). +Install Node.js from [http://nodejs.org](http://nodejs.org). ### Step 4: Install MongoDB on to your platform -To successfully use this sample, you must have a working installation of MongoDB. We will use MongoDB to make our REST API persistant across server instances. +To successfully use this sample, you must have a working installation of MongoDB. We will use MongoDB to make our REST API persistent across server instances. + +Install MongoDB from [http://mongodb.org](http://www.mongodb.org). -Install MongoDB from [http://mongodb.org](http://www.mongodb.org). +**NOTE:** This walkthrough assumes that you use the default installation and server endpoints for MongoDB, which at the time of this writing is: mongodb://localhost. This should work locally without any configuration changes if you run this sample on the same machine as you've installed and ran mongodb. -**NOTE:** This walkthrough assumes that you use the default installation and server endpoints for MongoDB, which at the time of this writing is: mongodb://localhost - ### Step 5: Download the Sample application and modules @@ -74,17 +48,35 @@ Next, clone the sample repo and install the NPM. From your shell or command line: -* `$ git clone git@github.com:WindowsAzureAD/Azure-AD-TODO-Server-Sample-For-Node.git` +* `$ git clone git@github.com:AzureADSamples/WebAPI-Nodejs.git` * `$ npm install` -### Step 6: Run the application +**Did you get an error?:** Restify provides a powerful mechanism to trace REST calls using DTrace. However, many operating systems do not have DTrace available. You can safely ignore these errors. + +* `$ cd node-server` +* `$ npm install` (yes, again) + +### Step 6: Configure your server using config.js + +You will need to update the sample to use your values for audienceURI and for the metadata endpoint. + +**NOTE:** You may also pass the `issuer:` value if you wish to validate that as well. + +### Step 7: Run the application * `$ cd node-server ` * `$ node server.js` +**Is the server output hard to understand?:** We use `bunyan` for logging in this sample. The console won't make much sense to you unless you also install bunyan and run the server like above but pipe it through the bunyan binary: + +* `$ node server.js | bunyan` + +### Your done! -### Acknowledgements +You will have a server successfully running on `http://localhost:8888`. Your REST / JSON API Endpoint will be `http://localhost:8888/tasks` + +### Acknowledgements We would like to acknowledge the folks who own/contribute to the following projects for their support of Windows Azure Active Directory and their libraries that were used to build this sample. In places where we forked these libraries to add additional functionality, we ensured that the chain of forking remains intact so you can navigate back to the original package. Working with such great partners in the open source community clearly illustrates what open collaboration can accomplish. Thank you! @@ -93,6 +85,7 @@ We would like to acknowledge the folks who own/contribute to the following proje - [Restify](http://mcavage.me/node-restify/) - Restify is a node.js module built specifically to enable you to build correct REST web services. ``` node-restify``` - [Restify-OAuth2](https://github.com/domenic/restify-oauth2) - This package provides a very simple OAuth 2.0 endpoint for the Restify framework. ``` restify-oauth2``` - [node-jwt-simple](https://github.com/hokaccha/node-jwt-simple) - Library for parsing JSON Web Tokens (JWT) ```node-jwt-simple``` +- [http-bearer-strategy](https://github.com/jaredhanson/passport-http-bearer) - HTTP Bearer authentication strategy for Passport and Node.js. @@ -100,4 +93,3 @@ We would like to acknowledge the folks who own/contribute to the following proje ## About The Code Code hosted on GitHub under Apache 2.0 license - diff --git a/node-server/app.js b/node-server/app.js new file mode 100644 index 0000000..1812a80 --- /dev/null +++ b/node-server/app.js @@ -0,0 +1,387 @@ +/* + Copyright (c) Microsoft Corporation + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + + 'use strict'; + + /** + * Module dependencies. + */ + + var fs = require('fs'); + var path = require('path'); + var util = require('util'); + var assert = require('assert-plus'); + var mongoose = require('mongoose/'); + var bunyan = require('bunyan'); + var restify = require('restify'); + var config = require('./config'); + var passport = require('passport'); + var OIDCBearerStrategy = require('./lib/passport-azure-ad/index').OIDCStrategy; + + + // We pass these options in to the ODICBearerStrategy. + + var options = { + // The URL of the metadata document for your app. We will put the keys for token validation from the URL found in the jwks_uri tag of the in the metadata. + identityMetadata: config.creds.identityMetadata, + // issuer: config.creds.issuer, + audience: config.creds.audience + +}; + + // array to hold logged in users and the current logged in user (owner) + var users = []; + var owner = null; + + // Our logger + var log = bunyan.createLogger({name: 'Windows Azure Active Directory Sample'}); + +// MongoDB setup +// Setup some configuration +var serverPort = process.env.PORT || 8888; +var serverURI = (process.env.PORT) ? config.creds.mongoose_auth_mongohq : config.creds.mongoose_auth_local; + +// Connect to MongoDB +global.db = mongoose.connect(serverURI); +var Schema = mongoose.Schema; +log.info('MongoDB Schema loaded'); + +// Here we create a schema to store our tasks and users. Pretty simple schema for now. +var TaskSchema = new Schema({ + owner: String, + task: String, + completed: Boolean, + date: Date +}); + +// Use the schema to register a model +mongoose.model('Task', TaskSchema); +var Task = mongoose.model('Task'); + + + +/** + * + * APIs for our REST Task server + */ + + // Create a task + +function createTask(req, res, next) { + + // Resitify currently has a bug which doesn't allow you to set default headers + // This headers comply with CORS and allow us to mongodbServer our response to any origin + + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "X-Requested-With"); + + // Create a new task model, fill it up and save it to Mongodb + var _task = new Task(); + + if (!req.params.task) { + req.log.warn({params: p}, 'createTodo: missing task'); + next(new MissingTaskError()); + return; + } + + _task.owner = owner; + _task.task = req.params.task; + _task.date = new Date(); + + _task.save(function (err) { + if (err) { + req.log.warn(err, 'createTask: unable to save'); + next(err); + } else { + res.send(201, _task); + + } + }); + + return next(); + +} + + + // Delete a task by name + +function removeTask(req, res, next) { + + Task.remove( { task:req.params.task, owner:owner }, function (err) { + if (err) { + req.log.warn(err, + 'removeTask: unable to delete %s', + req.params.task); + next(err); + } else { + log.info('Deleted task:', req.params.task); + res.send(204); + next(); + } + }); +} + + // Delete all tasks + +function removeAll(req, res, next) { + Task.remove(); + res.send(204); + return next(); +} + + +// Get a specific task based on name + +function getTask(req, res, next) { + + log.info('getTask was called for: ', owner); + Task.find({ owner: owner }, function (err, data) { + if (err) { + req.log.warn(err, 'get: unable to read %s', owner); + next(err); + return; + } + + res.json(data); + }); + + return next(); +} + + /// Simple returns the list of TODOs that were loaded. + +function listTasks(req, res, next) { + // Resitify currently has a bug which doesn't allow you to set default headers + // This headers comply with CORS and allow us to mongodbServer our response to any origin + + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "X-Requested-With"); + + log.info("listTasks was called for: ", owner); + + Task.find({ owner: owner }).limit(20).sort('date').exec(function (err,data) { + + if (err) + return next(err); + + if (data.length > 0) { + log.info(data); + } + + if (!data.length) { + log.warn(err, "There is no tasks in the database. Did you initalize the database as stated in the README?"); + } + + if (!owner) { + log.warn(err, "You did not pass an owner when listing tasks."); + } + + else { + + res.json(data); + + } + }); + + return next(); +} + +///--- Errors for communicating something interesting back to the client + +function MissingTaskError() { + restify.RestError.call(this, { + statusCode: 409, + restCode: 'MissingTask', + message: '"task" is a required parameter', + constructorOpt: MissingTaskError + }); + + this.name = 'MissingTaskError'; +} +util.inherits(MissingTaskError, restify.RestError); + + +function TaskExistsError(owner) { + assert.string(owner, 'owner'); + + restify.RestError.call(this, { + statusCode: 409, + restCode: 'TaskExists', + message: owner + ' already exists', + constructorOpt: TaskExistsError + }); + + this.name = 'TaskExistsError'; +} +util.inherits(TaskExistsError, restify.RestError); + + +function TaskNotFoundError(owner) { + assert.string(owner, 'owner'); + + restify.RestError.call(this, { + statusCode: 404, + restCode: 'TaskNotFound', + message: owner + ' was not found', + constructorOpt: TaskNotFoundError + }); + + this.name = 'TaskNotFoundError'; +} + +util.inherits(TaskNotFoundError, restify.RestError); + +/** + * Our Server + */ + + +var server = restify.createServer({ + name: "Windows Azure Active Directroy TODO Server", + version: "2.0.1" +}); + + // Ensure we don't drop data on uploads + server.pre(restify.pre.pause()); + + // Clean up sloppy paths like //todo//////1// + server.pre(restify.pre.sanitizePath()); + + // Handles annoying user agents (curl) + server.pre(restify.pre.userAgentConnection()); + + // Set a per request bunyan logger (with requestid filled in) + server.use(restify.requestLogger()); + + // Allow 5 requests/second by IP, and burst to 10 + server.use(restify.throttle({ + burst: 10, + rate: 5, + ip: true, + })); + + // Use the common stuff you probably want + server.use(restify.acceptParser(server.acceptable)); + server.use(restify.dateParser()); + server.use(restify.queryParser()); + server.use(restify.gzipResponse()); + server.use(restify.bodyParser({ mapParams: true})); // Allows for JSON mapping to REST + server.use(restify.authorizationParser()); // Looks for authorization headers + + // Let's start using Passport.js + + server.use(passport.initialize()); // Starts passport + server.use(passport.session()); // Provides session support + + /** + /* + /* Calling the OIDCBearerStrategy and managing users + /* + /* Passport pattern provides the need to manage users and info tokens + /* with a FindorCreate() method that must be provided by the implementor. + /* Here we just autoregister any user and implement a FindById(). + /* You'll want to do something smarter. + **/ + + var findById = function (id, fn) { + for (var i = 0, len = users.length; i < len; i++) { + var user = users[i]; + if (user.sub === id) { + log.info('Found user: ',user); + return fn(null, user); + } + } + return fn(null, null); + }; + + + var oidcStrategy = new OIDCBearerStrategy(options, + function(token, done) { + log.info('verifying the user'); + log.info(token, 'was the token retreived'); + findById(token.sub, function (err, user) { + if (err) { return done(err); } + if (!user) { + // "Auto-registration" + log.info('User was added automatically as they were new. Their sub is: ', token.sub); + users.push(token); + owner = token.sub; + return done(null, token); + } + owner = token.sub; + return done(null, user, token); + }); + } + ); + + passport.use(oidcStrategy); + + /// Now the real handlers. Here we just CRUD + + /** + /* + /* Each of these handlers are protected by our OIDCBearerStrategy by invoking 'oidc-bearer' + /* in the pasport.authenticate() method. We set 'session: false' as REST is stateless and + /* we don't need to maintain session state. You can experiement removing API protection + /* by removing the passport.authenticate() method like so: + /* + /* server.get('/tasks', listTasks); + /* + **/ + + server.get('/tasks', passport.authenticate('oidc-bearer', { session: false }), listTasks); + server.get('/tasks', passport.authenticate('oidc-bearer', { session: false }), listTasks); + server.get('/tasks/:owner', passport.authenticate('oidc-bearer', { session: false }), getTask); + server.head('/tasks/:owner', passport.authenticate('oidc-bearer', { session: false }), getTask); + server.post('/tasks/:owner/:task', passport.authenticate('oidc-bearer', { session: false }), createTask); + server.post('/tasks', passport.authenticate('oidc-bearer', { session: false }), createTask); + server.del('/tasks/:owner/:task', passport.authenticate('oidc-bearer', { session: false }), removeTask); + server.del('/tasks/:owner', passport.authenticate('oidc-bearer', { session: false }), removeTask); + server.del('/tasks', passport.authenticate('oidc-bearer', { session: false }), removeTask); + server.del('/tasks', passport.authenticate('oidc-bearer', { session: false }), removeAll, function respond(req, res, next) { res.send(204); next(); }); + + + // Register a default '/' handler + + server.get('/', function root(req, res, next) { + var routes = [ + 'GET /', + 'POST /tasks/:owner/:task', + 'POST /tasks (for JSON body)', + 'GET /tasks', + 'PUT /tasks/:owner', + 'GET /tasks/:owner', + 'DELETE /tasks/:owner/:task' + ]; + res.send(200, routes); + next(); + }); + + + server.listen(serverPort, function() { + + var consoleMessage = '\n Windows Azure Active Directory Tutorial'; + consoleMessage += '\n +++++++++++++++++++++++++++++++++++++++++++++++++++++'; + consoleMessage += '\n %s server is listening at %s'; + consoleMessage += '\n Open your browser to %s/tasks\n'; + consoleMessage += '+++++++++++++++++++++++++++++++++++++++++++++++++++++ \n'; + consoleMessage += '\n !!! why not try a $curl -isS %s | json to get some ideas? \n'; + consoleMessage += '+++++++++++++++++++++++++++++++++++++++++++++++++++++ \n\n'; + + //log.info(consoleMessage, server.name, server.url, server.url, server.url); + +}); diff --git a/node-server/config.js b/node-server/config.js index 7870631..b7e6b38 100644 --- a/node-server/config.js +++ b/node-server/config.js @@ -1,13 +1,6 @@ - // Don't commit this file to your public repos + // Don't commit this file to your public repos. This config is for first-run exports.creds = { mongoose_auth_local: 'mongodb://localhost/tasklist', // Your mongo auth uri goes here - token_endpoint: 'https://login.windows.net/xxxxxxxxxxxxxxxxx/oauth2/token', - auth_endpoint: 'https://login.windows.net/xxxxxxxxxxxxxxxxx/oauth2/authorize', - client_secret: '123', // this is the Secret you generated when configuring your Web API app on Azure AAD - - // required options - federation_metadata: 'https://login.windows.net/xxxxxxxxxxxxxxxxx/FederationMetadata.xml', // this is the metadata URL from the AAD Portal - loginCallback: 'http://localhost:8888', // this is the Callback URI you entered for APP ID URI when configuring your Web API app on Azure AAD - issuer: 'http://localhost:8888', // this is the URI you entered for APP ID URI when configuring your Web API app on Azure AAD - client_id: 'xxxxxxxxxxxxxxxxx' // this is the Client ID you received after configuring your Web API app on Azure AAD -} \ No newline at end of file + audience: 'https://localhost:8888', // the Audience is the App URL when you registered the application. + identityMetadata: 'https://login.microsoftonline.com/hypercubeb2c.onmicrosoft.com/.well-known/openid-configuration?p=b2c_1_B2CSI' // Replace the text after p= with your specific policy. + }; diff --git a/node-server/hooks.js b/node-server/hooks.js deleted file mode 100644 index 1516ae4..0000000 --- a/node-server/hooks.js +++ /dev/null @@ -1,80 +0,0 @@ -/* - Copyright (c) Microsoft Open Technologies, Inc. - All Rights Reserved - Apache License 2.0 - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -"use strict"; - -var _ = require("underscore"); -var crypto = require("crypto"); - -var database = { - clients: { - officialApiClient: { secret: "C0FFEE" }, - unofficialClient: { secret: "DECAF" } - }, - users: { - AzureDiamond: { password: "hunter2" }, - Cthon98: { password: "*********" } - }, - tokensToUsernames: {} -}; - -function generateToken(data) { - var random = Math.floor(Math.random() * 100001); - var timestamp = (new Date()).getTime(); - var sha256 = crypto.createHmac("sha256", random + "WOO" + timestamp); - - return sha256.update(data).digest("base64"); -} - -exports.validateClient = function (clientId, clientSecret, cb) { - // Call back with `true` to signal that the client is valid, and `false` otherwise. - // Call back with an error if you encounter an internal server error situation while trying to validate. - - var isValid = _.has(database.clients, clientId) && database.clients[clientId].secret === clientSecret; - cb(null, isValid); -}; - -exports.grantUserToken = function (username, password, cb) { - var isValid = _.has(database.users, username) && database.users[username].password === password; - if (isValid) { - // If the user authenticates, generate a token for them and store it so `exports.authenticateToken` below - // can look it up later. - - var token = generateToken(username + ":" + password); - database.tokensToUsernames[token] = username; - - // Call back with the token so Restify-OAuth2 can pass it on to the client. - return cb(null, token); - } - - // Call back with `false` to signal the username/password combination did not authenticate. - // Calling back with an error would be reserved for internal server error situations. - cb(null, false); -}; - -exports.authenticateToken = function (token, cb) { - if (_.has(database.tokensToUsernames, token)) { - // If the token authenticates, call back with the corresponding username. Restify-OAuth2 will put it in the - // request's `username` property. - var username = database.tokensToUsernames[token]; - return cb(null, username); - } - - // If the token does not authenticate, call back with `false` to signal that. - // Calling back with an error would be reserved for internal server error situations. - cb(null, false); -}; \ No newline at end of file diff --git a/node-server/aadutils.js b/node-server/lib/passport-azure-ad/aadutils.js similarity index 99% rename from node-server/aadutils.js rename to node-server/lib/passport-azure-ad/aadutils.js index 954130c..a0f5964 100644 --- a/node-server/aadutils.js +++ b/node-server/lib/passport-azure-ad/aadutils.js @@ -41,6 +41,3 @@ exports.getFirstElement = function (parentElement, elementName) { } return Array.isArray(element) ? element[0] : element; }; - - - diff --git a/node-server/lib/passport-azure-ad/index.js b/node-server/lib/passport-azure-ad/index.js new file mode 100644 index 0000000..4096730 --- /dev/null +++ b/node-server/lib/passport-azure-ad/index.js @@ -0,0 +1,22 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +"use strict"; + +exports.SamlStrategy = require('./samlstrategy'); +exports.WsfedStrategy = require('./wsfedstrategy'); +exports.OIDCStrategy = require('./oidcstrategy'); diff --git a/node-server/metadata.js b/node-server/lib/passport-azure-ad/metadata.js similarity index 53% rename from node-server/metadata.js rename to node-server/lib/passport-azure-ad/metadata.js index eda9b32..91a7966 100644 --- a/node-server/metadata.js +++ b/node-server/lib/passport-azure-ad/metadata.js @@ -21,13 +21,23 @@ var xml2js = require('xml2js'); var request = require('request'); var aadutils = require('./aadutils'); var async = require('async'); +var ursa = require('ursa'); -var Metadata = function (url) { +// Logging + +var bunyan = require('bunyan'); +var log = bunyan.createLogger({name: 'Microsoft OpenID Connect: Passport Strategy: Metadata Parser'}); + +var Metadata = function (url, authtype) { if(!url) { throw new Error("Metadata: url is a required argument"); } + if(!authtype) { + throw new Error('OIDCBearerStrategy requires an authentication type specified to metadata parser. Valid types are saml, wsfed, or odic"'); + } this.url = url; this.metadata = null; + this.authtype = authtype; }; Object.defineProperty(Metadata, 'url', { @@ -48,44 +58,18 @@ Object.defineProperty(Metadata, 'wsfed', { } }); -Object.defineProperty(Metadata, 'oauth', { +Object.defineProperty(Metadata, 'oidc', { get: function () { - return this.oauth; + return this.oidc; } }); - Object.defineProperty(Metadata, 'metadata', { get: function () { return this.metadata; } }); -exports.getElement = function (parentElement, elementName) { - if (parentElement['saml:' + elementName]) { - return parentElement['saml:' + elementName]; - } else if (parentElement['samlp:'+elementName]) { - return parentElement['samlp:'+elementName]; - } - return parentElement[elementName]; -}; - - -exports.getFirstElement = function (parentElement, elementName) { - var element = null; - - if (parentElement['saml:' + elementName]) { - element = parentElement['saml:' + elementName]; - } else if (parentElement['samlp:'+elementName]) { - element = parentElement['samlp:'+elementName]; - } else { - element = parentElement[elementName]; - } - return Array.isArray(element) ? element[0] : element; -}; - - - Metadata.prototype.updateSamlMetadata = function(doc, next) { try { this.saml = {}; @@ -100,9 +84,7 @@ Metadata.prototype.updateSamlMetadata = function(doc, next) { // copy the x509 certs from the metadata this.saml.certs = []; - for (var j=0;j'; + request += ''; + return request; +}; + +SAML.prototype.generateLogoutRequest = function (req) { + var id = "_" + samlutils.generateUniqueID(); + var instant = samlutils.generateInstant(); + var request = ''; + request += ' ' + req.user.nameID + ''; + request += ''; + return request; +}; + +SAML.prototype.requestToUrl = function (request, operation, callback) { + var self = this; + async.waterfall([ + function(next){ + if(!self.metadata.saml0) { + self.metadata.fetch(next); + } else { + next(null); + } + }, + function(next){ + zlib.deflateRaw(request, function(err, buffer) { + if (err) { + return callback(err); + } + + var base64 = buffer.toString('base64'); + var target = self.metadata.saml.loginEndpoint + '?'; + var samlRequest = { + SAMLRequest: base64 + }; + if (operation === 'logout') { + target = self.metadata.saml.logoutEndpoint + '?'; + if (self.options.privateCert) { + samlRequest.SigAlg = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'; + samlRequest.Signature = self.signRequest(querystring.stringify(samlRequest)); + } + } + + target += querystring.stringify(samlRequest); + + return next(null, target); + }); + } + ], function (err, target) { + return callback(err, target); + }); +}; + +SAML.prototype.getAuthorizeUrl = function (req, callback) { + var request = this.generateAuthorizeRequest(); + this.requestToUrl(request, 'authorize', callback); +}; + +SAML.prototype.getLogoutUrl = function(req, callback) { + var request = this.generateLogoutRequest(req); + this.requestToUrl(request, 'logout', callback); +}; + +SAML.prototype.validateSignature = function (xml, cert) { + var doc = new xmldom.DOMParser().parseFromString(xml); + var signature = xmlCrypto.xpath(doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0]; + var sig = new xmlCrypto.SignedXml(); + sig.keyInfoProvider = { + getKeyInfo: function () { + return ""; + }, + getKey: function () { + //TODO: should I use the key in keyInfo or in cert? + return pem.certToPEM(cert); + } + }; + sig.loadSignature(signature.toString()); + return sig.checkSignature(xml); +}; + +SAML.prototype.checkSamlStatus = function(response, next) { + try { + var status = aadutils.getElement(response, 'Status'); + var statusCode = aadutils.getElement(status[0], 'StatusCode'); + var result = aadutils.getElement(statusCode[0].$, 'Value'); + if(result === SamlUrn.success) { + next(null); + } else { + next(new Error('SAML response error:' + JSON.stringify(status)),null); + } + } catch (e) { + next(new Error('Invalid SAML response:' + e.message),null); + } +}; + +SAML.prototype.validateResponse = function (samlResponse, callback) { + var self = this, + xml = null, + version = '', + response = null; + + // asynchronously process the samlResponse to create the user profile + async.waterfall([ + // parse the samlResponse into a JavaScript object + function(next){ + xml = new Buffer(samlResponse, 'base64').toString('ascii'); + var parser = new xml2js.Parser({explicitRoot:true}); + parser.parseString(xml, function (err, doc) { + response = aadutils.getElement(doc, 'Response'); + next(null); + }); + }, + function(next){ + // check for an error in the samlResponse + self.checkSamlStatus(response, next); + }, + function(next) { + // check version of SAML response + if (response['$'].MajorVersion === '1') { + version = '1.1'; + } else if (response['$'].Version === '2.0') { + version = '2.0'; + } + + if(version === '') { + next(new Error('SAML Assertion version not supported'), null); + } else { + next(null); + } + }, + function(next) { + // check for token expiration + var assertion = response.Assertion[0]; + if (!samlutils.validateExpiration(assertion, version)) { + next(new Error('Token has expired.'), null); + } else { + next(null); + } + }, + function(next) { + // check for valid audience + var assertion = response.Assertion[0]; + if (!samlutils.validateAudience(assertion, self.options.issuer, version)) { + next(new Error('Token has expired.'), null); + } else { + next(null); + } + }, + function(next){ + // check to see if we have loaded the x509 certs from the AAD metadata url + if(!self.metadata.saml || self.metadata.saml.certs.length === 0) { + self.metadata.fetch(next); + } else { + next(null); + } + }, + function(next){ + // validate the Signature + self.checkSignature(xml, next); + }, + function(next) { + self.getProfile(response, next); + } + ], function (err, profile) { + // return the err and profile to the caller + callback(err, profile, false); + }); +}; + +SAML.prototype.checkSignature = function(xml, next) { + // validate the Signature + var self = this; + if(!this.validateSignature(xml, this.metadata.saml.certs[0])) { + // if signature validation fails, perhaps the certs have changed on the AAD tenant? + // reload the AAD Federation metadata and try again + this.metadata.fetch(function(err) { + if(err) { + next(err); + } else { + // validate the Signature + if(!self.validateSignature(xml, self.metadata.saml.certs[0])) { + next(new Error('Invalid signature')); + } else { + next(null); + } + } + }); + } else { + next(null); + } +}; + +SAML.prototype.getProfile = function (response, callback) { + var assertion, + profile = {}; + + assertion = aadutils.getElement(response, 'Assertion'); + if (!assertion) { + return callback(new Error('getProfile: Missing SAML assertion')); + } + + try { + profile = samlutils.getProfile(assertion); + return callback(null, profile); + } catch(e) { + callback(new Error("getProfile error:" + e.message)); + } +}; + +exports.SAML = SAML; diff --git a/node-server/lib/passport-azure-ad/samlstrategy.js b/node-server/lib/passport-azure-ad/samlstrategy.js new file mode 100644 index 0000000..cc9d394 --- /dev/null +++ b/node-server/lib/passport-azure-ad/samlstrategy.js @@ -0,0 +1,100 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +"use strict"; + +var passport = require('passport'); +var util = require('util'); +var saml = require('./saml'); + +function Strategy (options, verify) { + if (typeof options === 'function') { + verify = options; + options = {}; + } + + if (!verify) { + throw new Error('SAML authentication strategy requires a verify function'); + } + + this.name = 'saml'; + + passport.Strategy.call(this); + + this._verify = verify; + this._saml = new saml.SAML(options); +} + +util.inherits(Strategy, passport.Strategy); + +Strategy.prototype.authenticate = function (req) { + var self = this; + if (req.body && req.body.SAMLResponse) { + // We have a response, get the user identity out of it + var response = req.body.SAMLResponse; + + this._saml.validateResponse(response, function (err, profile, loggedOut) { + if (err) { + return self.error(err); + } + + if (loggedOut) { + if (self._saml.options.logoutRedirect) { + self.redirect(self._saml.options.logoutRedirect); + return; + } else { + self.redirect("/"); + } + + } + + var verified = function (err, user, info) { + if (err) { + return self.error(err); + } + + if (!user) { + return self.fail(info); + } + + self.success(user, info); + }; + + self._verify(profile, verified); + }); + } else { + // Initiate new SAML authentication request + + this._saml.getAuthorizeUrl(req, function (err, url) { + if (err) { + return self.fail(); + } + + self.redirect(url); + }); + } +}; + +Strategy.prototype.logout = function(req, callback) { + this._saml.getLogoutUrl(req, callback); +}; + +Strategy.prototype.identity = function(callback) { + this._saml.identity(callback); +}; + +module.exports = Strategy; diff --git a/node-server/lib/passport-azure-ad/samlutils.js b/node-server/lib/passport-azure-ad/samlutils.js new file mode 100644 index 0000000..77956a2 --- /dev/null +++ b/node-server/lib/passport-azure-ad/samlutils.js @@ -0,0 +1,166 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +'use strict'; + +var aadutils = require('./aadutils'); + +var SamlAttributes = exports.SamlAttributes = { + identityprovider: 'http://schemas.microsoft.com/identity/claims/identityprovider', + name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', + givenname: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', + surname: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname', + objectidentifier: 'http://schemas.microsoft.com/identity/claims/objectidentifier' +}; + + +exports.validateExpiration = function (samlAssertion, version) { + var conditions, + notBefore, + notOnOrAfter, + now = new Date(); + + if(version !== '2.0') { + throw new Error('validateExpiration: invalid SAML assertion. Only version 2.0 is supported.'); + } + try { + conditions = Array.isArray(samlAssertion.Conditions) ? samlAssertion.Conditions[0].$ : samlAssertion.Conditions; + notBefore = new Date(conditions.NotBefore); + notBefore = notBefore.setMinutes(notBefore.getMinutes() - 10); // 10 minutes clock skew + + notOnOrAfter = new Date(conditions.NotOnOrAfter); + notOnOrAfter = notOnOrAfter.setMinutes(notOnOrAfter.getMinutes() + 10); // 10 minutes clock skew + + if (now < notBefore || now > notOnOrAfter) { + return false; + } + + return true; + } catch (e) { + // rethrow exceptions + throw e; + } +}; + +exports.validateAudience = function (samlAssertion, realm, version) { + var conditions, + restrictions, + audience; + + if(version !== '2.0') { + throw new Error('validateAudience: invalid SAML assertion. Only version 2.0 is supported.'); + } + + try { + conditions = Array.isArray(samlAssertion.Conditions) ? samlAssertion.Conditions[0] : samlAssertion.Conditions; + restrictions = Array.isArray(conditions.AudienceRestriction) ? conditions.AudienceRestriction[0] : conditions.AudienceRestriction; + audience = Array.isArray(restrictions.Audience) ? restrictions.Audience[0]: restrictions.Audience; + return audience === realm; + } catch (e) { + // rethrow exceptions + throw e; + } +}; + + +exports.getProfile = function (assertion) { + var profile = {}; + + assertion = Array.isArray(assertion) ? assertion[0] : assertion; + + var issuer = aadutils.getFirstElement(assertion, 'Issuer'); + if (issuer) { + profile.issuer = issuer; + } + + var subject = aadutils.getFirstElement(assertion, 'Subject'); + if (subject) { + var nameID = aadutils.getFirstElement(subject, 'NameID'); + if (nameID) { + profile.nameID = nameID; + profile.nameIDFormat = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'; + } + } + + var attributeStatement = aadutils.getFirstElement(assertion, 'AttributeStatement'); + if (!attributeStatement) { + throw new Error('Missing AttributeStatement'); + } + + var attributes = aadutils.getElement(attributeStatement, 'Attribute'); + + if (attributes) { + attributes.forEach(function (attribute) { + var value = aadutils.getFirstElement(attribute, 'AttributeValue'); + if (typeof value === 'string') { + profile[attribute.$.Name] = value; + } else { + profile[attribute.$.Name] = value._; + } + }); + } + + if (!profile.provider && profile[SamlAttributes.identityprovider]) { + profile.provider = profile[SamlAttributes.identityprovider]; + } + + if (!profile.id && profile[SamlAttributes.objectidentifier]) { + profile.id = profile[SamlAttributes.objectidentifier]; + } + + if (!profile.mail && profile[SamlAttributes.name]) { + profile.mail = profile[SamlAttributes.name]; + } + + if (!profile.givenName && profile[SamlAttributes.givenname]) { + profile.givenName = profile[SamlAttributes.givenname]; + } + + if (!profile.familyName && profile[SamlAttributes.surname]) { + profile.familyName = profile[SamlAttributes.surname]; + } + + if (!profile.displayName) { + if(profile[SamlAttributes.givenname]) { + profile.displayName = profile[SamlAttributes.givenname]; + } else if(profile[SamlAttributes.surname]) { + profile.displayName = profile[SamlAttributes.surname]; + } else { + profile.displayName = ''; + } + } + + if (!profile.email && profile.mail) { + profile.email = profile.mail; + } + + return profile; +}; + +exports.generateUniqueID = function () { + var chars = "abcdef0123456789"; + var uniqueID = ""; + for (var i = 0; i < 20; i++) { + uniqueID += chars.substr(Math.floor((Math.random()*15)), 1); + } + return uniqueID; +}; + +exports.generateInstant = function () { + var date = new Date(); + return date.getUTCFullYear() + '-' + ('0' + (date.getUTCMonth()+1)).slice(-2) + '-' + ('0' + date.getUTCDate()).slice(-2) + 'T' + ('0' + date.getUTCHours()).slice(-2) + ":" + ('0' + date.getUTCMinutes()).slice(-2) + ":" + ('0' + date.getUTCSeconds()).slice(-2) + "Z"; +}; diff --git a/node-server/lib/passport-azure-ad/templates/federationmetadata.template.xml b/node-server/lib/passport-azure-ad/templates/federationmetadata.template.xml new file mode 100644 index 0000000..db58d6c --- /dev/null +++ b/node-server/lib/passport-azure-ad/templates/federationmetadata.template.xml @@ -0,0 +1,26 @@ + + + + + + + <%= CERT %> + + + + + + + + <%= ORGANIZATON_NAME %> + <%= ORGANIZATON_DISPLAY_NAME %> + <%= ORGANIZATON_URL %> + + + <%= GIVEN_NAME %> + <%= SURNAME %> + <%= EMAIL %> + + + + diff --git a/node-server/lib/passport-azure-ad/templates/templates.js b/node-server/lib/passport-azure-ad/templates/templates.js new file mode 100644 index 0000000..b783c99 --- /dev/null +++ b/node-server/lib/passport-azure-ad/templates/templates.js @@ -0,0 +1,57 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +"use strict"; + +var fs = require('fs'); +var PATH = require('path'); +var _ = require('underscore'); + +exports.loadSync = function (name) { + var path = PATH.join(__dirname, name); + return fs.readFileSync(path, 'utf8'); +}; + +exports.compileSync = function (template, params) { + return _.template(template ,params); +}; + +exports.load = function (name, callback) { + var path = PATH.join(__dirname, name); + fs.readFile(path, 'utf8', function (err, data) { + callback(err, data); + }); +}; + +exports.compile = function (name, params, callback) { + var path = PATH.join(__dirname, name); + try { + fs.readFile(path, 'utf8', function (err, data) { + if(err) { + callback(err); + } else { + try { + callback(null, _.template(data, params)); + } catch(e) { + callback(new Error('Template Error: ' + name + " " + e.message)); + } + } + }); + } catch(e) { + callback(new Error(e.message)); + } +}; diff --git a/node-server/lib/passport-azure-ad/validator.js b/node-server/lib/passport-azure-ad/validator.js new file mode 100644 index 0000000..1675751 --- /dev/null +++ b/node-server/lib/passport-azure-ad/validator.js @@ -0,0 +1,71 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Validator adapted from JavaScript Patterns by Stoyan Stefanov (O'Reilly), Copyright 2010 Yahoo!, Inc., 9780596806750 + */ + +'use strict'; + +var types = {}; + +var Validator = function (config) { + this.config = config; +}; + +Validator.prototype.validate = function (options) { + var item, + type, + checker; + + if (!options) { + options = {}; + } + + for(item in this.config) { + if(this.config.hasOwnProperty(item)) { + type = this.config[item]; + if(!type){ + continue; // no need to validate + } + checker = types[type]; + if(!checker) { // missing required checker + throw { + name: 'ValidationError', + message: 'No handler to validate type ' + type + ' for item ' + item + }; + } + + if(!checker.validate(options[item])) { + throw new Error('Invalid value for ' + item + '. ' + checker.error); + } + } else { + throw new Error('Missing value for ' + item); + + } + } +}; + +Validator.isNonEmpty = 'isNonEmpty'; +types.isNonEmpty = { + validate: function(value) { + return value !== '' && value !== undefined && value !== null; + }, + error:'The value cannot be empty' +}; + + + +exports.Validator = Validator; \ No newline at end of file diff --git a/node-server/lib/passport-azure-ad/wsfederation.js b/node-server/lib/passport-azure-ad/wsfederation.js new file mode 100644 index 0000000..021576d --- /dev/null +++ b/node-server/lib/passport-azure-ad/wsfederation.js @@ -0,0 +1,106 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +'use strict'; + +var xmldom = require('xmldom'); +var xtend = require('xtend'); +var qs = require('querystring'); + +var WsFederation = module.exports = function WsFederation (options) { + this.realm = options.realm; + + if(options.homeRealm) { + this.homerealm = options.homerealm; + } else { + this.homerealm = ''; + } + this.identityProviderUrl = options.identityProviderUrl; + this.wreply = options.wreply; + this.logoutUrl = options.logoutUrl; +}; + + +WsFederation.prototype.getRequestSecurityTokenUrl = function (options, callback) { + + var query = xtend(options || {}, { + wtrealm: this.realm, + wa: 'wsignin1.0' + }); + + if (this.homerealm) { + query.whr = this.homerealm; + } else { + query.whr = ''; + + } + + if (this.wreply) { + query.wreply = this.wreply; + } + + callback(null,this.identityProviderUrl + '?' + qs.encode(query)); +}; + +WsFederation.prototype.logout = function (options, callback) { + + var query = xtend(options || {}, { + wtrealm: this.realm, + wa: 'wsignout1.0' + }); + + if (this.homerealm) { + query.whr = this.homerealm; + } else { + query.whr = ''; + } + + query.wreply = this.logoutUrl; + + callback(null,this.identityProviderUrl + '?' + qs.encode(query)); +}; + + +WsFederation.prototype.extractToken = function(req) { + + var doc = new xmldom.DOMParser().parseFromString(req.body['wresult']); + var token = doc.getElementsByTagNameNS('http://schemas.xmlsoap.org/ws/2005/02/trust', 'RequestedSecurityToken')[0].firstChild; + var tokenString = new xmldom.XMLSerializer().serializeToString(token); + + return tokenString; +}; + +Object.defineProperty(WsFederation, 'realm', { + get: function () { + return this.realm; + } +}); + +Object.defineProperty(WsFederation, 'homeRealm', { + get: function () { + return this.homeRealm; + } +}); + +Object.defineProperty(WsFederation, 'identityProviderUrl', { + get: function () { + return this.identityProviderUrl; + }, + set: function (url) { + this.identityProviderUrl = url; + } +}); diff --git a/node-server/lib/passport-azure-ad/wsfedsaml.js b/node-server/lib/passport-azure-ad/wsfedsaml.js new file mode 100644 index 0000000..985d878 --- /dev/null +++ b/node-server/lib/passport-azure-ad/wsfedsaml.js @@ -0,0 +1,138 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + credits to: https://github.com/bergie/passport-saml + */ + + +'use strict'; + +var xml2js = require('xml2js'); +var xmlCrypto = require('xml-crypto'); +var crypto = require('crypto'); +var xmldom = require('xmldom'); +var pem = require('./pem'); +var aadutils = require('./aadutils'); +var samlutils = require('./samlutils'); + +var SAML = function (options) { + this.options = options; + + if(options.metadata) { + this.certs = options.metadata.certs; + } else { + if (!options.cert) { + throw new Error('You must set a X509Certificate certificate from the federationmetadata.xml file for your app'); + } else { + this.certs = []; + this.certs.push(options.cert); + } + } +}; + +Object.defineProperty(SAML, 'certs', { + get: function () { + return this.certs; + }, + set: function (certs) { + this.certs = []; + if(Array.isArray(certs)) { + this.certs = certs; + } else { + this.certs = []; + this.certs.push(certs); + } + } +}); + +SAML.prototype.validateSignature = function (xml, cert, thumbprint) { + var self = this; + var doc = new xmldom.DOMParser().parseFromString(xml); + var signature = xmlCrypto.xpath(doc, "/*/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']")[0]; + var sig = new xmlCrypto.SignedXml(null, { idAttribute: 'AssertionID' }); + sig.keyInfoProvider = { + getKeyInfo: function () { + return ""; + }, + getKey: function (keyInfo) { + if (thumbprint) { + var embeddedSignature = keyInfo[0].getElementsByTagName("X509Certificate"); + if (embeddedSignature.length > 0) { + var base64cer = embeddedSignature[0].firstChild.toString(); + var shasum = crypto.createHash('sha1'); + var der = new Buffer(base64cer, 'base64').toString('binary'); + shasum.update(der); + self.calculatedThumbprint = shasum.digest('hex'); + + return pem.certToPEM(base64cer); + } + } + return pem.certToPEM(cert); + } + }; + sig.loadSignature(signature.toString()); + var valid = sig.checkSignature(xml); + + if (cert) { + return valid; + } + + if (thumbprint) { + return valid && this.calculatedThumbprint.toUpperCase() === thumbprint.toUpperCase(); + } +}; + +SAML.prototype.validateResponse = function (samlAssertionString, callback) { + var self = this; + + // Verify signature + if (!self.validateSignature(samlAssertionString, this.certs[0], self.options.thumbprint)) { + return callback(new Error('Invalid signature'), null); + } + + var parser = new xml2js.Parser({explicitRoot:true, explicitArray:false}); + parser.parseString(samlAssertionString, function (err, doc) { + + var samlAssertion = aadutils.getElement(doc, 'Assertion'); + var version = ''; + if (samlAssertion['$'].MajorVersion === '1') { + version = '1.1'; + } + else if (samlAssertion['$'].Version === '2.0') { + version = '2.0'; + } + else { + return callback(new Error('SAML Assertion version not supported'), null); + } + + if (!samlutils.validateExpiration(samlAssertion, version)) { + return callback(new Error('Token has expired.'), null); + } + + if (!samlutils.validateAudience(samlAssertion, self.options.realm, version)) { + return callback(new Error('Audience is invalid. Expected: ' + self.options.realm), null); + } + + try { + var profile = samlutils.getProfile(samlAssertion); + return callback(null, profile); + } catch(e) { + return callback(new Error("getProfile error:" + e.message)); + } + }); +}; + +exports.SAML = SAML; diff --git a/node-server/lib/passport-azure-ad/wsfedstrategy.js b/node-server/lib/passport-azure-ad/wsfedstrategy.js new file mode 100644 index 0000000..cebb055 --- /dev/null +++ b/node-server/lib/passport-azure-ad/wsfedstrategy.js @@ -0,0 +1,141 @@ +/* + Copyright (c) Microsoft Open Technologies, Inc. + All Rights Reserved + Apache License 2.0 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +'use strict'; + + +var passport = require('passport'); +var util = require('util'); +var saml = require('./wsfedsaml'); +var wsfed = require('./wsfederation'); +var Metadata = require('./metadata').Metadata; + + +function Strategy (options, verify) { + if (typeof options === 'function') { + verify = options; + options = {}; + } + + if (!verify) { + throw new Error('Windows Azure Access Control Service authentication strategy requires a verify function'); + } + + this.name = 'wsfed-saml2'; + + passport.Strategy.call(this); + + if (!options.realm) { + throw new Error('options.realm is required.'); + } + + if (!options.logoutUrl) { + throw new Error('options.logoutUrl is required.'); + } + + this.realm = options.realm; + this.certs = []; + + // Create the metadata object if the user has specified a federation metadata url + if(options.identityMetadata) { + this.metadata = new Metadata(options.identityMetadata); + this.identityProviderUrl = null; + } else { + if (!options.cert) { + throw new Error('options.cert is required. You must set a X509Certificate certificate from the federationmetadata.xml file for your app'); + } + if (!options.identityProviderUrl) { + throw new Error('option.identityProviderUrl is required You must set the identityProviderUrl for your app'); + } + + this.metadata = null; + this.identityProviderUrl = options.identityProviderUrl; + this.certs.push(options.cert); + } + + options.metadata = this.metadata; + + this._verify = verify; + this._saml = new saml.SAML(options); + this._wsfed = new wsfed(options); +} + +util.inherits(Strategy, passport.Strategy); + +Strategy.prototype.authenticate = function (req) { + var self = this, + wsfed; + + if(this.metadata && !this.metadata.wsfed) { + this.metadata.fetch(function(err) { + if(err) { + return this.error(err); + } else { + wsfed = self.metadata.wsfed; + self._saml.certs = wsfed.certs; + self._wsfed.identityProviderUrl = wsfed.loginEndpoint; + self._doAuthenticate(req); + } + }); + } else { + self._doAuthenticate(req); + } +}; + +Strategy.prototype.logout = function(options, callback) { + this._wsfed.logout(options, callback); +}; + +Strategy.prototype._doAuthenticate = function (req) { + var self = this; + + if (req.body && req.method === 'POST') { + // We have a response, get the user identity out of it + var token = this._wsfed.extractToken(req); + self._saml.validateResponse(token, function (err, profile) { + if (err) { + return self.error(err); + } + + var verified = function (err, user, info) { + if (err) { + return self.error(err); + } + + if (!user) { + return self.fail(info); + } + + self.success(user, info); + }; + + self._verify(profile, verified); + }); + } else { + // Initiate new ws-fed authentication request + this._wsfed.getRequestSecurityTokenUrl({}, function(err, url) { + if(err) { + return self.error(err); + } else { + return self.redirect(url); + } + }); + } +}; + + +module.exports = Strategy; diff --git a/node-server/package.json b/node-server/package.json index 8f38c4b..3dd93e9 100644 --- a/node-server/package.json +++ b/node-server/package.json @@ -1,62 +1,14 @@ { - "name": "azure-activedirectory-library-for-ios", + "name": "passport-azure-ad-login-oidc", "version": "0.0.1", - "licenses": [ - { - "type": "Apache 2.0", - "url": "https://github.com/MSOpenTech/aal/raw/master/License.txt" - } - ], - "keywords": [ - "saml", - "samlp", - "wsfed", - "azure active directory", - "aad", - "adfs", - "sso", - "shibboleth" - ], - "description": "Windows Azure Active Directroy OAuth2 Client Credentials Server for node", - "author": { - "name": "Brandon Werner", - "email": "brandon.werner@microsoft.com", - "url": "http://msopentech.com/" - }, "dependencies": { + "express": "3.x", + "ejs": ">= 0.0.0", + "ejs-locals": ">= 0.0.0", "restify": "*", - "node-jwt": "*", - "underscore": "*", - "crypto": "*", - "assert-plus": "*", - "posix-getopt": "*", "mongoose": "*", - "mongodb" : "*", - "util" : "*", - "path" : "*", - "connect": "*", - "passport": "*", - "passport-oauth": "*", - "xml2js": "0.2.6", - "xml-crypto": "0.0.x", - "xmldom": "0.1.x", - "async": "~0.2.7", - "request": "~2.16.6", - "underscore": "~1.4.4", - "xtend": "~2.0.3", - "bunyan": "*" - - }, - "devDependencies": { - "grunt-contrib-jshint": "~0.1.1", - "grunt-contrib-watch": "~0.2.0", - "grunt": "~0.4.1", - "bunyan": "*" - }, - "scripts": { - "test": "grunt" - }, - "engines": { - "node": ">= 0.8.0" + "bunyan": "*", + "assert-plus": "*", + "passport": "*" } -} \ No newline at end of file +} diff --git a/node-server/server.js b/node-server/server.js deleted file mode 100644 index f8c4ec9..0000000 --- a/node-server/server.js +++ /dev/null @@ -1,441 +0,0 @@ -/* - Copyright (c) Microsoft Open Technologies, Inc. - All Rights Reserved - Apache License 2.0 - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - - 'use strict'; - - /** - * Module dependencies. - */ - - var fs = require('fs'); - var path = require('path'); - var util = require('util'); - var assert = require('assert-plus'); - var bunyan = require('bunyan'); - var getopt = require('posix-getopt'); - var mongoose = require('mongoose/'); - var restify = require('restify'); - var config = require('./config'); - var aadutils = require('./aadutils'); - var Metadata = require('./metadata').Metadata; - - /* -* -* Load our old friend Passport for OAuth2 flows -*/ - -var passport = require('passport') - , OAuth2Strategy = require('passport-oauth').OAuth2Strategy; - -/** -* Setup some configuration -*/ -var serverPort = process.env.PORT || 8888; -var serverURI = ( process.env.PORT ) ? config.creds.mongoose_auth_mongohq : config.creds.mongoose_auth_local; -var tokenEndpoint = config.creds.token_endpoint; -var authEndpoint = config.creds.auth_endpoint; -var fedEndpoint = config.creds.federation_metadata; -var clientID = config.creds.client_id; -var clientSecret = config.creds.client_secret; -var callbackURL = config.creds.loginCallback; -this.metadata = new Metadata(config.creds.federation_metadata); - - -/** -* -* Connect to MongoDB -*/ - -global.db = mongoose.connect(serverURI); -var Schema = mongoose.Schema; - -/** -/ Here we create a schema to store our tasks. Pretty simple schema for now. -*/ - -var TaskSchema = new Schema({ - name: String, - task: String, - completed: Boolean, - date: Date -}); - -// Use the schema to register a model - -mongoose.model('Task', TaskSchema); -var Task = mongoose.model('Task'); - -/** - * - * APIs - */ - -function createTask(req, res, next) { - - // Resitify currently has a bug which doesn't allow you to set default headers - // This headers comply with CORS and allow us to mongodbServer our response to any origin - - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "X-Requested-With"); - - // Create a new task model, fill it up and save it to Mongodb - var _task = new Task(); - - if (!req.params.task) { - req.log.warn('createTask: missing task'); - next(new MissingTaskError()); - return; - } - -/* if (Task.find(req.params.task)) { - req.log.warn('%s already exists', req.params.task); - next(new TaskExistsError(req.params.task)); - return; - }*/ - - - _task.name = req.params.name; - _task.task = req.params.task; - _task.date = new Date(); - - _task.save(function (err) { - if (err) { - req.log.warn(err, 'createTask: unable to save'); - next(err); - } else { - res.send(201, _task); - - } - }); - - return next(); - -} - - -/** - * Deletes a Task by name - */ -function removeTask(req, res, next) { - - Task.remove( { task:req.params.task }, function (err) { - if (err) { - req.log.warn(err, - 'removeTask: unable to delete %s', - req.params.task); - next(err); - } else { - res.send(204); - next(); - } - }); -} - -/** - * Deletes all Tasks. A wipe - */ -function removeAll(req, res, next) { - Task.remove(); - res.send(204); - return next(); -} - - -/** - * - * - * - */ -function getTask(req, res, next) { - - - Task.find(req.params.name, function (err, data) { - if (err) { - req.log.warn(err, 'get: unable to read %s', req.params.name); - next(err); - return; - } - - res.json(data); - }); - - return next(); -} - - -/** - * Simple returns the list of TODOs that were loaded. - * - */ - -function listTasks(req, res, next) { - // Resitify currently has a bug which doesn't allow you to set default headers - // This headers comply with CORS and allow us to mongodbServer our response to any origin - - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Headers", "X-Requested-With"); - - console.log("server getTasks"); - - Task.find().limit(20).sort('date').exec(function (err,data) { - - if (err) - return next(err); - - if (data.length > 0) { - console.log(data); - } - - if (!data.length) { - console.log('there was a problem'); - console.log(err); - console.log("There is no tasks in the database. Did you initalize the database as stated in the README?"); - } - - else { - - res.json(data); - - } - }); - - return next(); -} - -///--- Errors for communicating something interesting back to the client - -function MissingTaskError() { - restify.RestError.call(this, { - statusCode: 409, - restCode: 'MissingTask', - message: '"task" is a required parameter', - constructorOpt: MissingTaskError - }); - - this.name = 'MissingTaskError'; -} -util.inherits(MissingTaskError, restify.RestError); - - -function TaskExistsError(name) { - assert.string(name, 'name'); - - restify.RestError.call(this, { - statusCode: 409, - restCode: 'TaskExists', - message: name + ' already exists', - constructorOpt: TaskExistsError - }); - - this.name = 'TaskExistsError'; -} -util.inherits(TaskExistsError, restify.RestError); - - -function TaskNotFoundError(name) { - assert.string(name, 'name'); - - restify.RestError.call(this, { - statusCode: 404, - restCode: 'TaskNotFound', - message: name + ' was not found', - constructorOpt: TaskNotFoundError - }); - - this.name = 'TaskNotFoundError'; -} - -util.inherits(TaskNotFoundError, restify.RestError); - -/** - * Our Server - */ - - -var server = restify.createServer({ - name: "Windows Azure Active Directroy TODO Server", - version: "1.0.0", - formatters: { - 'application/json': function(req, res, body){ - if(req.params.callback){ - var callbackFunctionName = req.params.callback.replace(/[^A-Za-z0-9_\.]/g, ''); - return callbackFunctionName + "(" + JSON.stringify(body) + ");"; - } else { - return JSON.stringify(body); - } - }, - 'text/html': function(req, res, body){ - if (body instanceof Error) - return body.stack; - - if (Buffer.isBuffer(body)) - return body.toString('base64'); - - return util.inspect(body); - }, - 'application/x-www-form-urlencoded': function(req, res, body){ - if (body instanceof Error) { - res.statusCode = body.statusCode || 500; - body = body.message; - } else if (typeof (body) === 'object') { - body = body.task || JSON.stringify(body); - } else { - body = body.toString(); - } - - res.setHeader('Content-Length', Buffer.byteLength(body)); - return (body); - } - } -}); - - // Ensure we don't drop data on uploads - server.pre(restify.pre.pause()); - - // Clean up sloppy paths like //todo//////1// - server.pre(restify.pre.sanitizePath()); - - // Handles annoying user agents (curl) - server.pre(restify.pre.userAgentConnection()); - - // Set a per request bunyan logger (with requestid filled in) - server.use(restify.requestLogger()); - - // Allow 5 requests/second by IP, and burst to 10 - server.use(restify.throttle({ - burst: 10, - rate: 5, - ip: true, - })); - - // Use the common stuff you probably want - server.use(restify.acceptParser(server.acceptable)); - server.use(restify.dateParser()); - server.use(restify.queryParser()); - server.use(restify.gzipResponse()); - - /// Now the real handlers. Here we just CRUD - - server.get('/tasks', passport.authenticate('provider', { session: false }), listTasks); - server.head('/tasks', passport.authenticate('provider', { session: false }), listTasks); - server.get('/tasks/:name', getTask); - server.head('/tasks/:name', getTask); - server.post('/tasks/:name/:task', createTask); - server.del('/tasks/:name/:task', removeTask); - server.del('/tasks/:name', removeTask); - server.del('/tasks', removeAll, function respond(req, res, next) { res.send(204); next(); }); - - - // Register a default '/' handler - - server.get('/', function root(req, res, next) { - var routes = [ - 'GET /', - 'POST /tasks/:name/:task', - 'GET /tasks', - 'PUT /tasks/:name', - 'GET /tasks/:name', - 'DELETE /tasks/:name/:task' - ]; - res.send(200, routes); - next(); - }); - - server.use(restify.authorizationParser()); - server.use(restify.bodyParser({ mapParams: false })); - - // Now our own handlers for authentication/authorization - // Here we only use Oauth2 from Passport.js - -passport.use('provider', new OAuth2Strategy({ - authorizationURL: authEndpoint, - tokenURL: tokenEndpoint, - clientID: clientID, - clientSecret: clientSecret, - callbackURL: callbackURL - }, - function(accessToken, refreshToken, profile, done) { - User.findOrCreate({ UserId: profile.id }, function(err, user) { - done(err, user); - }); - } -)); - -// Let's start using Passport.js - -server.use(passport.initialize()); - -// Redirect the user to the OAuth 2.0 provider for authentication. When -// complete, the provider will redirect the user back to the application at -// /auth/provider/callback - -server.get('/auth/provider', passport.authenticate('provider')); - -// The OAuth 2.0 provider has redirected the user back to the application. -// Finish the authentication process by attempting to obtain an access -// token. If authorization was granted, the user will be logged in. -// Otherwise, authentication has failed. - -server.get('/auth/provider/callback', - passport.authenticate('provider', { successRedirect: '/', - failureRedirect: '/login' })); - - -// Simple route middleware to ensure user is authenticated. -// Use this route middleware on any resource that needs to be protected. If -// the request is authenticated (typically via a persistent login session), -// the request will proceed. Otherwise, the user will be redirected to the -// login page. - -var ensureAuthenticated = function(req, res, next) { - if (req.isAuthenticated()) { - return next(); - } - res.redirect('/login'); -}; - - -// Passport session setup. -// To support persistent login sessions, Passport needs to be able to -// serialize users into and deserialize users out of the session. Typically, -// this will be as simple as storing the user ID when serializing, and finding -// the user by ID when deserializing. -passport.serializeUser(function(user, done) { - done(null, user.email); -}); - -passport.deserializeUser(function(id, done) { - findByEmail(id, function (err, user) { - done(err, user); - }); -}); - - - - server.listen(serverPort, function() { - - var consoleMessage = '\n Windows Azure Active Directory Tutorial' - consoleMessage += '\n +++++++++++++++++++++++++++++++++++++++++++++++++++++' - consoleMessage += '\n %s server is listening at %s'; - consoleMessage += '\n Open your browser to %s/tasks\n'; - consoleMessage += '+++++++++++++++++++++++++++++++++++++++++++++++++++++ \n' - consoleMessage += '\n !!! why not try a $curl -isS %s | json to get some ideas? \n' - consoleMessage += '+++++++++++++++++++++++++++++++++++++++++++++++++++++ \n\n' - - console.log(consoleMessage, server.name, server.url, server.url, server.url); - -}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..52eba97 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "passport-azure-ad", + "version": "0.0.1", + "licenses": [ + { + "type": "Apache 2.0", + "url": "https://github.com/MSOpenTech/aal/raw/master/License.txt" + } + ], + "keywords": [ + "saml", + "samlp", + "wsfed", + "odic", + "open id connect", + "azure active directory", + "azure", + "aad", + "adfs", + "sso", + "shibboleth" + ], + "description": "WSFED/SAMLP 2.0/ODIC authentication Passport strategies for Azure Active Directory", + "author": { + "name": "Microsoft Corp", + "email": "brandon.werner@microsoft.com", + "url": "http://microsoft.com/" + }, + "repository": { + "type": "git", + "url": "git@github.com:AzureAD/passport-azure-ad.git" + }, + "main": "./lib/passport-azure-ad", + "dependencies": { + "passport": "0.1.x", + "xml2js": "0.2.6", + "xml-crypto": "0.0.x", + "xmldom": "0.1.x", + "async": "~0.2.7", + "request": "~2.16.6", + "querystring": "~0.2.0", + "underscore": "~1.4.4", + "xtend": "~2.0.3", + "passport-http-bearer": "~1.0.1", + "ursa": "~0.8.4", + "jwt": "~0.2.0", + "jws": "~3.0.0", + "jsonwebtoken": "~5.0.1" + }, + "devDependencies": { + "grunt-contrib-jshint": "~0.1.1", + "grunt-contrib-nodeunit": "~0.1.2", + "grunt-contrib-watch": "~0.2.0", + "grunt": "~0.4.1", + "bunyan": "~1.3.5" + }, + "scripts": { + "test": "grunt nodeunit" + }, + "engines": { + "node": ">= 0.8.0" + } +}