├── .gitignore ├── adfs-oauth-tests.js ├── adfs-oauth_configure.html ├── adfs-oauth_package.js ├── adfs-oauth.js ├── adfs-oauth_configure.js ├── .versions ├── package.js ├── adfs-oauth_client.js ├── adfs-oauth_server.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .build* 4 | .npm 5 | -------------------------------------------------------------------------------- /adfs-oauth-tests.js: -------------------------------------------------------------------------------- 1 | // Write your tests here! 2 | // Here is an example. 3 | Tinytest.add('example', function (test) { 4 | test.equal(true, true); 5 | }); 6 | -------------------------------------------------------------------------------- /adfs-oauth_configure.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /adfs-oauth_package.js: -------------------------------------------------------------------------------- 1 | var jwt = Npm.require('jwt-simple'); 2 | var fs = Npm.require('fs'); 3 | 4 | //var projectPath = process.env.PWD; //not compatible with windows 5 | var projectPath = process.cwd(); 6 | 7 | verifyToken = function (sToken, sPublicCertPath, sSignature) { 8 | var publicCert = fs.readFileSync(projectPath + sPublicCertPath, 'utf8'); 9 | var tokenDecoded = jwt.decode(sToken, publicCert, 'RS256'); 10 | return tokenDecoded; 11 | }; 12 | -------------------------------------------------------------------------------- /adfs-oauth.js: -------------------------------------------------------------------------------- 1 | Accounts.oauth.registerService('adfsoauth'); 2 | 3 | if (Meteor.isClient) { 4 | Meteor.loginWithAdfsoauth = function(options, callback) { 5 | // support a callback without options 6 | if (! callback && typeof options === "function") { 7 | callback = options; 8 | options = null; 9 | } 10 | 11 | var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); 12 | Adfsoauth.requestCredential(options, credentialRequestCompleteCallback); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /adfs-oauth_configure.js: -------------------------------------------------------------------------------- 1 | Template.configureLoginServiceDialogForAdfsoauth.helpers({ 2 | siteUrl: function () { 3 | return Meteor.absoluteUrl(); 4 | } 5 | }); 6 | 7 | Template.configureLoginServiceDialogForAdfsoauth.fields = function () { 8 | return [ 9 | {property: 'clientId', label: 'Client ID'}, 10 | {property: 'secret', label: 'Client secret', value: 'none'}, 11 | {property: 'publicCertPath', label: 'ADFS Public Certificate Path'}, 12 | {property: 'resource', label: 'Relying Party Trust Identifier'}, 13 | {property: 'profileNameField', label: 'Field for profile name mapping'}, 14 | {property: 'oauthAdfsUrl', label: 'URL to ADFS backend'} 15 | ]; 16 | }; 17 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.7 2 | accounts-oauth@1.1.12 3 | allow-deny@1.0.4 4 | babel-compiler@6.6.4 5 | babel-runtime@0.1.8 6 | base64@1.0.8 7 | binary-heap@1.0.8 8 | blaze@2.1.7 9 | blaze-tools@1.0.8 10 | boilerplate-generator@1.0.8 11 | caching-compiler@1.0.4 12 | caching-html-compiler@1.0.6 13 | callback-hook@1.0.8 14 | check@1.2.1 15 | ddp@1.2.5 16 | ddp-client@1.2.7 17 | ddp-common@1.2.5 18 | ddp-rate-limiter@1.0.4 19 | ddp-server@1.2.6 20 | deps@1.0.12 21 | diff-sequence@1.0.5 22 | ecmascript@0.4.3 23 | ecmascript-runtime@0.2.10 24 | ejson@1.0.11 25 | geojson-utils@1.0.8 26 | html-tools@1.0.9 27 | htmljs@1.0.9 28 | http@1.1.5 29 | id-map@1.0.7 30 | jquery@1.11.8 31 | local-test:snowping:adfs-oauth@0.0.2 32 | localstorage@1.0.9 33 | logging@1.0.12 34 | meteor@1.1.14 35 | minifier-js@1.1.11 36 | minimongo@1.0.16 37 | modules@0.6.1 38 | modules-runtime@0.6.3 39 | mongo@1.1.7 40 | mongo-id@1.0.4 41 | npm-mongo@1.4.43 42 | oauth@1.1.10 43 | oauth2@1.1.9 44 | observe-sequence@1.0.11 45 | ordered-dict@1.0.7 46 | promise@0.6.7 47 | random@1.0.9 48 | rate-limit@1.0.4 49 | reactive-var@1.0.9 50 | reload@1.1.8 51 | retry@1.0.7 52 | routepolicy@1.0.10 53 | service-configuration@1.0.9 54 | snowping:adfs-oauth@0.0.2 55 | spacebars@1.0.11 56 | spacebars-compiler@1.0.11 57 | templating@1.1.9 58 | templating-tools@1.0.4 59 | tinytest@1.0.10 60 | tracker@1.0.13 61 | ui@1.0.11 62 | underscore@1.0.8 63 | url@1.0.9 64 | webapp@1.2.8 65 | webapp-hashing@1.0.9 66 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'snowping:adfs-oauth', 3 | version: '0.0.3', 4 | summary: 'Oauth2 authentication using Microsoft ADFS3 Oauth service (Active Directory)', 5 | // URL to the Git repository containing the source code for this package. 6 | git: '', 7 | documentation: 'README.md' 8 | }); 9 | 10 | Package.onUse(function(api) { 11 | api.versionsFrom('1.1.0.3'); 12 | api.use(['underscore', 'random']); 13 | api.use('oauth2', ['client', 'server']); 14 | api.use('oauth', ['client', 'server']); 15 | api.use('http', ['server']); 16 | api.use(['underscore', 'service-configuration'], ['client', 'server']); 17 | api.use(['random', 'templating'], 'client'); 18 | 19 | api.use('accounts-base', ['client', 'server']); 20 | 21 | // Export Accounts (etc) to packages using this one. 22 | api.imply('accounts-base', ['client', 'server']); 23 | api.use('accounts-oauth', ['client', 'server']); 24 | 25 | //Add npm module files 26 | api.addFiles(['adfs-oauth_package.js'], ['server']); 27 | 28 | api.addFiles(['adfs-oauth_configure.html', 'adfs-oauth_configure.js'], 'client'); 29 | api.addFiles('adfs-oauth_server.js', 'server'); 30 | api.addFiles('adfs-oauth_client.js', 'client'); 31 | api.addFiles('adfs-oauth.js'); 32 | 33 | }); 34 | 35 | Package.onTest(function(api) { 36 | api.use('tinytest'); 37 | api.use('snowping:adfs-oauth'); 38 | api.addFiles('adfs-oauth-tests.js'); 39 | }); 40 | 41 | //NPM module dependencies 42 | Npm.depends({ 43 | 'jwt-simple': '0.3.1' 44 | }); 45 | -------------------------------------------------------------------------------- /adfs-oauth_client.js: -------------------------------------------------------------------------------- 1 | Adfsoauth = {}; 2 | 3 | // Request credentials for the user 4 | // @param options {optional} 5 | // @param credentialRequestCompleteCallback {Function} Callback function to call on 6 | // completion. Takes one argument, credentialToken on success, or Error on 7 | // error. 8 | Adfsoauth.requestCredential = function (options, credentialRequestCompleteCallback) { 9 | // support both (options, callback) and (callback). 10 | if (!credentialRequestCompleteCallback && typeof options === 'function') { 11 | credentialRequestCompleteCallback = options; 12 | options = {}; 13 | } else if (!options) { 14 | options = {}; 15 | } 16 | 17 | var config = ServiceConfiguration.configurations.findOne({service: 'adfsoauth'}); 18 | if (!config) { 19 | credentialRequestCompleteCallback && credentialRequestCompleteCallback( 20 | new ServiceConfiguration.ConfigError()); 21 | return; 22 | } 23 | 24 | var credentialToken = Random.secret(); 25 | 26 | var loginUrlParameters = {}; 27 | if (config.loginUrlParameters){ 28 | _.extend(loginUrlParameters, config.loginUrlParameters) 29 | } 30 | if (options.loginUrlParameters){ 31 | _.extend(loginUrlParameters, options.loginUrlParameters) 32 | } 33 | var ILLEGAL_PARAMETERS = ['response_type', 'client_id', 'scope', 'redirect_uri', 'state']; 34 | // validate options keys 35 | _.each(_.keys(loginUrlParameters), function (key) { 36 | if (_.contains(ILLEGAL_PARAMETERS, key)) 37 | throw new Error("Adfsoauth.requestCredential: Invalid loginUrlParameter: " + key); 38 | }); 39 | 40 | // backwards compatible options 41 | if (options.requestOfflineToken != null){ 42 | loginUrlParameters.access_type = options.requestOfflineToken ? 'offline' : 'online' 43 | } 44 | if (options.prompt != null) { 45 | loginUrlParameters.prompt = options.prompt; 46 | } else if (options.forceApprovalPrompt) { 47 | loginUrlParameters.prompt = 'consent' 48 | } 49 | 50 | var loginStyle = OAuth._loginStyle('adfsoauth', config, options); 51 | _.extend(loginUrlParameters, { 52 | "response_type": "code", 53 | "client_id": config.clientId, 54 | "resource": config.resource, 55 | "redirect_uri": OAuth._redirectUri('adfsoauth', config), 56 | "state": OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl) 57 | }); 58 | var loginUrl = config.oauthAdfsUrl + '/authorize?' + 59 | _.map(loginUrlParameters, function(value, param){ 60 | return encodeURIComponent(param) + '=' + encodeURIComponent(value); 61 | }).join("&"); 62 | 63 | OAuth.launchLogin({ 64 | loginService: "adfsoauth", 65 | loginStyle: loginStyle, 66 | loginUrl: loginUrl, 67 | credentialRequestCompleteCallback: credentialRequestCompleteCallback, 68 | credentialToken: credentialToken, 69 | popupOptions: { height: 600 } 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /adfs-oauth_server.js: -------------------------------------------------------------------------------- 1 | Adfsoauth = {}; 2 | 3 | OAuth.registerService('adfsoauth', 2, null, function (query) { 4 | var config = ServiceConfiguration.configurations.findOne({service: 'adfsoauth'}); 5 | if (!config) 6 | throw new ServiceConfiguration.ConfigError(); 7 | 8 | var response = getTokens(query); 9 | var expiresAt = (+new Date) + (1000 * parseInt(response.expiresIn, 10)); 10 | var accessToken = response.accessToken; 11 | var identity = getIdentity(accessToken); 12 | 13 | var serviceData = { 14 | accessToken: accessToken, 15 | expiresAt: expiresAt 16 | }; 17 | 18 | //Add fields from jwt token to the serviceData 19 | _.extend(serviceData, identity); 20 | 21 | // only set the token in serviceData if it's there. this ensures 22 | // that we don't lose old ones (since we only get this on the first 23 | // log in attempt) 24 | if (response.refreshToken) 25 | serviceData.refreshToken = response.refreshToken; 26 | 27 | return { 28 | serviceData: serviceData, 29 | options: {profile: {name: identity[config.profileNameField]}} 30 | }; 31 | }); 32 | 33 | // returns an object containing: 34 | // - accessToken 35 | // - expiresIn: lifetime of token in seconds 36 | // - refreshToken, if this is the first authorization request 37 | var getTokens = function (query) { 38 | var config = ServiceConfiguration.configurations.findOne({service: 'adfsoauth'}); 39 | if (!config) 40 | throw new ServiceConfiguration.ConfigError(); 41 | 42 | var response; 43 | try { 44 | response = HTTP.post( 45 | config.oauthAdfsUrl + "/token", { 46 | npmRequestOptions: { 47 | rejectUnauthorized: false //allow self signed certificates 48 | }, 49 | params: { 50 | code: query.code, 51 | client_id: config.clientId, 52 | client_secret: OAuth.openSecret(config.secret), 53 | redirect_uri: OAuth._redirectUri('adfsoauth', config), 54 | grant_type: 'authorization_code' 55 | } 56 | }); 57 | 58 | } catch (err) { 59 | console.log(err); 60 | throw _.extend(new Error("Failed to complete OAuth handshake with Adfsoauth. " + err.message), 61 | {response: err.response}); 62 | } 63 | 64 | if (response.data.error) { // if the http response was a json object with an error attribute 65 | throw new Error("Failed to complete OAuth handshake with Adfsoauth. " + response.data.error); 66 | } else { 67 | return { 68 | accessToken: response.data.access_token, 69 | refreshToken: response.data.refresh_token, 70 | expiresIn: response.data.expires_in, 71 | idToken: response.data.id_token 72 | }; 73 | } 74 | }; 75 | 76 | var getIdentity = function (accessToken) { 77 | var config = ServiceConfiguration.configurations.findOne({service: 'adfsoauth'}); 78 | if (!config) 79 | throw new ServiceConfiguration.ConfigError(); 80 | 81 | try { 82 | return verifyToken(accessToken, config.publicCertPath, 'RS256'); 83 | } catch (err) { 84 | throw _.extend(new Error("Failed to fetch identity from ADFS jwt token. " + err.message), 85 | {response: err.response}); 86 | } 87 | }; 88 | 89 | Adfsoauth.retrieveCredential = function (credentialToken, credentialSecret) { 90 | return OAuth.retrieveCredential(credentialToken, credentialSecret); 91 | }; 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meteor package in order to authenticate using Microsoft ADFS3 Oauth service 2 | 3 | **Do you want to achieve Active Directory authentication using your meteor web app?** 4 | 5 | This meteor package allows you to authenticate to a Microsoft ADFS3 Oauth service. The package was greatly inspired by the offical oauth packages *accounts-google* and *google*. 6 | 7 | >**Please note that this is a prototype implementation. Use it at your own risk**. 8 | 9 | ### Features 10 | - Compatibility with accounts-ui package 11 | - Online configuration using loginServiceConfiguration (accounts-ui) 12 | - Server Token Validation using jwt-simple 13 | 14 | ### Todo 15 | - Add unit/integration tests 16 | - Ability to use encrypted tokens (decrypt using private cert) 17 | - Security review 18 | 19 | ### Side note 20 | Oauth has been widely used as an authentication architecture for modern web applications, especially in order to integrate trusted third party accounts like Facebook, Twitter, Google etc. In Enterprise environments though the adoption is quite small. Microsoft however released the ability to use Oauth2 with the new version ADFS 3.0 (Active Directory Federation Services 3.0). It comes by default with Windows 2012 R2 Enterprise ([more details](https://technet.microsoft.com/en-us/library/dn633593.aspx)). Unfortunately, the oauth implementation of Microsoft slightly differs from standard specification ([RFC 6749](http://tools.ietf.org/html/rfc6749)) and implements only a [subset](http://blogs.technet.com/b/maheshu/archive/2015/04/28/oauth-2-0-support-in-adfs-on-windows-server-2012-r2.aspx) of the features. This package aims to help you get started using AD Authentication in your own meteor project. 21 | 22 | ### Installation 23 | 24 | #### Pre-Requirements 25 | - Windows 2012 Domain 26 | - Windows Server 2012 R2 Server with configured ADFS 3.0 server role 27 | - For installation and configuration of ADFS 3 refer to: https://technet.microsoft.com/en-us/library/dn452410.aspx 28 | - For lab installation you might need a self-signed SSL-Certificate for your ADFS service. For easy online generation of certificates I can recommend http://www.getacert.com/ (supports wildcard certifications) 29 | 30 | #### Setup ADFS for your meteor app 31 | 32 | 1. Start powershell console as Administrator 33 | 2. Create adfs client id for your meteor app 34 | ``` 35 | Add-ADFSClient -Name "Meteor Demo App" -ClientId "meteordemoapp" -RedirectUri "http://localhost:3000/_oauth/adfsoauth" 36 | ``` 37 | 3. Check your configuration using 38 | ``` 39 | Get-ADFSClient 40 | ``` 41 | => Example output 42 | ``` 43 | RedirectUri : {http://localhost:3000/_oauth/adfsoauth} 44 | Name : Meteor Demo App 45 | Description : 46 | ClientId : meteordemoapp 47 | BuiltIn : False 48 | Enabled : True 49 | ClientType : Public 50 | ``` 51 | 52 | 4. Add Relying Party Trust 53 | ``` 54 | AD FS Mangement -> Trust Relationships -> Relying Party Trusts -> Add Relying Party Trust... 55 | ``` 56 | - Enter data about the relying party manually 57 | - Display name e.g. Meteor Demo App 58 | - AD FS Profile 59 | - Optional encryption: TODO! -> fow now skip with next 60 | - Relying party trust identifier e.g. meteordemoapp (name is used with resource param, see workflow below) 61 | - Rest leave default 62 | 63 | 5. Add user fields from Active Directory (e.g. commonname & email) 64 | ``` 65 | AD FS Mangement -> Trust Relationships -> Relying Party Trusts -> -> Edit Claim Rules... -> Add Rule... 66 | ``` 67 | - Claim rule name: any name 68 | - Attribute store: Active Directory 69 | - Mapping of LDAP attributes to outgoing claim types 70 | 71 | | LDAP Atribute | Outgoing Claim Type | 72 | | ------------------- |--------------------:| 73 | | Given-Name | Given Name | 74 | | Surname | Surname | 75 | | User-Principal-Name | Common Name | 76 | | E Mail-Addresses | E-Mail Address | 77 | 78 | - IMPORTANT: Add new transformation rule to map an id field (required by Meteor oauth) 79 | - Example: Incoming claim "UPN" --> custom claim "id" 80 | 81 | #### Setup oauth within meteor app 82 | - Install package 83 | ``` 84 | meteor add snowping:adfs-oauth 85 | ``` 86 | - GUI Configuration: Configure package - go to your app e.g. http://localhost:3000, click on 'Register' -> 'CONFIGURE ADFSOAUTH' 87 | - Client ID : meteordemoapp 88 | - Client secret: none 89 | - ADFS Public Certificate Path : /private/certs/cert.cer 90 | - Relying Party Trust Identifier : meteordemoapp 91 | - Field for profile name mapping : commonname 92 | - URL to ADFS backend : https://your-adfs-host/adfs/oauth2 93 | 94 | > Client secret is not required by the ADFS Oauth but inherited by default from official oauth package, just use 'none' here 95 | 96 | - Without GUI: 97 | ``` 98 | meteor add service-configuration 99 | ``` 100 | 101 | ``` 102 | Meteor.startup(function() { 103 | ServiceConfiguration.configurations.upsert( 104 | { service: "adfsoauth" }, 105 | { 106 | $set: { 107 | clientId: "meteordemoapp", 108 | loginStyle: "popup", 109 | secret: "none", 110 | publicCertPath : "/private/certs/cert.cer", 111 | resource : "meteordemoapp", 112 | profileNameField : "commonname", 113 | oauthAdfsUrl : "https://your-adfs-host/adfs/oauth2" 114 | } 115 | } 116 | ); 117 | }); 118 | ``` 119 | 120 | - Optional auto login code, useful when the only auth available => oauth workflow starts without requiring users to click sign in) 121 | ``` 122 | if (Meteor.isClient) { 123 | Meteor.startup(function () { 124 | if (Meteor.user()) { 125 | console.log('User logged in!'); 126 | } else { 127 | console.log('User logged out!'); 128 | Meteor.loginWithAdfsoauth(); //Auto login using ADFS Oauth 129 | } 130 | }); 131 | } 132 | ``` 133 | 134 | ### Debugging/Troubleshooting ADFS Oauth worklow 135 | 136 | #### Debug and analyze auth workflow 137 | **When debugging with Chrome/Postman you should import the self signed certificate to the "System" key chain of your Chrome browser** 138 | 139 | - Authorization request in order to get authorization code (GET request from your browser) 140 | ``` 141 | https:///adfs/oauth2/authorize?response_type=code&client_id=>&redirect_uri=https:///_oauth/adfsoauth&resource= 142 | ``` 143 | - Param 1 "response_type" => value "code" is required to request a new token 144 | - Param 2 "client_id" => value "123456" is a registered adfsclient, use command "Get-AdfsClient" to list all clients on server 145 | - Param 3 "redirect_uri" => where to redirect and apply the code param e.g. "http://localhost:3000/_oauth/adfsoauth&code=lsdkjflsjdflkjd8234lk324o7234kjn23kl4j..." 146 | - Param 4 "resource" => Relying Trusted Party (it is required in params but not used by your meteor app, you can use a fake one here) 147 | 148 | - If request is successful it should show you either a login form or redirect you straight away (kerberos auth when already within domain) 149 | - Request new token 150 | ``` 151 | https:///adfs/oauth2/token 152 | ``` 153 | - Param 1 "grant_type" => value "authorization_code" (token to access protected resource) 154 | - Param 2 "client_id" => value e.g. "123456" (registered adfs client) 155 | - Param 3 "redirect_uri" => value e.g. "http://localhost:3000/_oauth/adfsoauth" (where to send token to) 156 | - Param 4 "code" => value "" received from step above through get param 157 | 158 | >This step should be done on server as recommended by Oauth definition (this meteor package does that) => grant_type: authorization_code. Normally in such request we have also a param "client_secret" included which should be saved somewhere safe within the server (backend). This makes the oauth autheration even more secure. Only trusted clients can issue tokens! However, ADFS 3 Oauth does currently not support "client_secret" parameter. Therefore, requests can be made on servers and clients (browser). 159 | 160 | - Get JWT token as JSON response (example response) 161 | ``` 162 | { 163 | "access_token": "eyJ0eXAiOiJKV1QiLCJhbG...", 164 | "token_type": "bearer", 165 | "expires_in": 3600 166 | } 167 | ``` 168 | - JWT are encoded with Header and Payload data (not encrypted) 169 | - Resource server or protected resource must validate the signature using the public key of the used certificate to encode the token 170 | - Payload data can include username, email, address fields etc. - by default only the following fields are included (example response): 171 | ``` 172 | { 173 | "aud": "microsoft:identityserver:meteorapptest", 174 | "iss": "http:///adfs/services/trust", 175 | "iat": 1442853194, 176 | "exp": 1442856794, 177 | "auth_time": "2015-09-21T16:33:14.577Z", 178 | "authmethod": "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", 179 | "ver": "1.0", 180 | "appid": "meteordemoapp" 181 | } 182 | ``` 183 | Refer to *Setup ADFS for your meteor app* from above in order to extend the payload with user fields 184 | 185 | #### Troubleshooting ADFS errors (eventlog on windows server) 186 | - The Kerberos client received a KRB_AP_ERR_MODIFIED error from the server fs.service 187 | 188 | ``` 189 | setspn -D http/srv2012r2test.dev.intra.domain.ch 190 | setspn -A http/srv2012r2test.dev.intra.domain.ch fs.service 191 | ``` 192 | Refer to http://blogs.technet.com/b/dcaro/archive/2013/07/04/fixing-the-security-kerberos-4-error.aspx for more details. 193 | 194 | 195 | License 196 | ---- 197 | 198 | MIT 199 | --------------------------------------------------------------------------------