3 | Have you already prepared ADFS for your meteor app? 4 |
5 |If not, please see the instructions here
6 | 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 ->" 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 |
--------------------------------------------------------------------------------