diff --git a/docs/changelog.rst b/docs/changelog.rst index 368f31f5..1a028440 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,7 +24,7 @@ configuration must be removed. In it's place you will need to configure the following properties: - **API Token**: similar to the Stormpath API Key, this is a secret that is used - to secure the communication with the Okta platorm. + to secure the communication with the Okta platform. - **Application Id**: This is the Okta Application that represents your application. The migration tool should have created this automatically for you. - **Org**: In Stormpath you had a Tenant, and in Okta you have an Org. Every @@ -52,22 +52,60 @@ Or through the following environment variables: **Breaking Changes** -- ``req.app.get('stormpathApplication')`` will be undefined. +- Subdomain-based multi-tenancy, as introduced by version 3.2.0, will not be supported. If you are using this feature please contact support@stormpath.com so that we can help you find a solution. + +- ``req.app.get('stormpathApplication')`` will now be an Okta application, which does not have the same capabilities as a Stormpath application. We will provide more information on this in the future, likely as a changelog to the underlying Node SDK. + +- Custom data properties must be declared on the Okta User Schema. If you have used the `web.register.form.fields` configuration to add custom properties to your registration form, you will need to use the Okta Admin Console to add these to the user schema. This can be found under Directory -> Profile Editor. + +- Email verification has several major changes: + + - You will have to send the email verification message to your users. Stormpath was able to send this email for you, but this is not yet available in Okta. We've provided a new option for you to pass an ``emailVerificationHandler``, this handler will be called when a new user registers, or when a user is asking for the verification email to be re-sent. This function is passed the account, which will have the email verification token that you need to send to the user. See example below. + + - Email verification is now a global configuration, rather than a per-directory option. It must be enabled with the ``web.register.emailVerificationRequired`` option. + + - The email verification token is still found on the ``account.emailVerificationToken.href`` property like before, but it no longer has a full URL in front of it. We've retained an initial forward slash in case you were using this as part of a Regular Expression when looking for the token. + + Here is how the configuration for email verification should now look: + + .. code-block:: javascript + + app.use(stormpath.init(app, { + web: { + register: { + emailVerificationRequired: true + } + }, + emailVerificationHandler: function(account) { + /** + * Drop the initial slash from the token, then append it to the verification URL + * of your application. Then send this link the user by email. + */ + + var token = account.emailVerificationToken.href.slice(1); + + var verificationUrl = 'http://www.example.com/verify?sptoken=' + token; + + var userEmail = account.email; + + var message = 'To verify you account please visit: ' + verificationUrl; + + sendEmail(userEmail, message); // pseudo code, sendEmail must be provided by you + }, + + })); **Potentially Breaking Changes** +- Okta uses an API Token to authenticate its API, similar to Stormpath's API Key ID/Secret. However the Okta API Tokens will expire in 30 days if they are not used. This means that if your application is not used for 30 days it will fail because the API Token will no longer be valid. + - ``req.user`` is now populated from the Okta User, which will contain a new set - of default properties that Stormpath did not have. We've copied the relevent - Okta properties onto their Stormpath counterparts (e.g. firstNamae, lastName, - and customData), however there will be new properties that did not exist before. + of default properties that Stormpath did not have. We've copied the relevant + Okta properties onto their Stormpath counterparts (e.g. ``firstName``, ``lastName``, + and ``customData``), however there will be new properties that did not exist before. Please evaluate how you are using ``req.user`` to ensure that the new properties won't break your code. -- ``req.user.emailVerificationStatus`` is now derived from the Okta User status. - If an account has reached the ``ACTIVE`` state, we consider `emailVerificationStatus` - to be ``VERIFIED``. This may not match any custom logic you had around Stormpath's - email verification feature. Please see the `Okta User Status`_ documentation for - more information about then new status scheme. Version 3.2.0 ------------- diff --git a/lib/controllers/register.js b/lib/controllers/register.js index c3f494e5..0aeb4db8 100644 --- a/lib/controllers/register.js +++ b/lib/controllers/register.js @@ -2,7 +2,9 @@ var async = require('async'); var url = require('url'); +var uuid = require('uuid'); +var Account = require('stormpath/lib/resource/Account'); var helpers = require('../helpers'); /** @@ -92,6 +94,24 @@ function applyDefaultAccountFields(stormpathConfig, req) { } } +/** + * Translates user creation errors into an end-user friendly userMessage + * @param {*} err + */ +function userCreationErrorMapper(err) { + if (err && err.errorCauses) { + err.errorCauses.forEach(function (cause) { + if (cause.errorSummary === 'login: An object with this field already exists in the current organization') { + err.userMessage = 'An account with that email address already exists.'; + } else if (!err.userMessage) { + // This clause allows the first error cause to be returned to the user + err.userMessage = cause.errorSummary; + } + }); + } + return err; +} + /** * Register a new user -- either via a JSON API, or via a browser. * @@ -103,10 +123,11 @@ function applyDefaultAccountFields(stormpathConfig, req) { */ module.exports = function (req, res, next) { var application = req.app.get('stormpathApplication'); + var client = req.app.get('stormpathClient'); var config = req.app.get('stormpathConfig'); + var emailVerificationHandler = config.emailVerificationHandler; var logger = req.app.get('stormpathLogger'); var view = config.web.register.view; - var accountStore = req.organization || application; function handlePreRegistration(formData, callback) { var preRegistrationHandler = config.preRegistrationHandler; @@ -154,46 +175,63 @@ module.exports = function (req, res, next) { } helpers.prepAccountData(req.body, config, function (accountData) { - if (req.organization) { - accountData.accountStore = { - href: req.organization.href - }; - } - accountStore.createAccount(accountData, function (err, account) { - if (err) { - return helpers.writeJsonError(res, err); - } + var oktaUser = Account.prototype.toOktaUser.call(accountData, { + customDataStrategy: config.customDataStrategy + }); - if (config.web.register.autoLogin) { - var options = { - username: req.body.email, - password: req.body.password - }; + var options = { + activate: true + }; - if (req.organization) { - options.accountStore = req.organization.href; - } + oktaUser.profile.emailVerificationStatus = 'UNVERIFIED'; + oktaUser.profile.emailVerificationToken = uuid.v4(); - return helpers.authenticate(options, req, res, function (err, account, authResult) { - if (err) { - return helpers.writeJsonError(res, err); - } + if (config.web.register.emailVerificationRequired) { + options.activate = false; + } - helpers.createSession(authResult, account, req, res); + client.createUser(oktaUser, options, function (err, account) { - handleResponse(account, defaultJsonResponse); - }); + if (err) { + return helpers.writeJsonError(res, userCreationErrorMapper(err)); } - helpers.expandAccount(account, config.expand, logger, function (err, expandedAccount) { + client.createResource(application.href + '/users', { + id: account.id, + scope: 'USER', + credentials: { + userName: account.email + } + }, function (err) { + if (err) { return helpers.writeJsonError(res, err); } - req.user = expandedAccount; + if (config.web.register.emailVerificationRequired) { + emailVerificationHandler(account); + } + + if (config.web.register.autoLogin) { + var options = { + username: req.body.email, + password: req.body.password + }; - handleResponse(expandedAccount, defaultJsonResponse); + return helpers.authenticate(options, req, res, function (err, account, authResult) { + if (err) { + return helpers.writeJsonError(res, err); + } + + helpers.createSession(authResult, account, req, res); + + handleResponse(account, defaultJsonResponse); + }); + } + + req.user = account; + handleResponse(account, defaultJsonResponse); }); }); }); @@ -261,15 +299,49 @@ module.exports = function (req, res, next) { }; } - accountStore.createAccount(accountData, function (err, account) { + var oktaUser = Account.prototype.toOktaUser.call(accountData, { + customDataStrategy: config.customDataStrategy + }); + + var options = { + activate: true + }; + + oktaUser.profile.emailVerificationStatus = 'UNVERIFIED'; + oktaUser.profile.emailVerificationToken = uuid.v4(); + + if (config.web.register.emailVerificationRequired) { + options.activate = false; + } + + client.createUser(oktaUser, options, function (err, account) { + + if (err) { logger.info('A user tried to create a new account, but this operation failed with an error message: ' + err.developerMessage); - callback(err); - } else { + return callback(userCreationErrorMapper(err)); + } + + client.createResource(application.href + '/users', { + id: account.id, + scope: 'USER', + credentials: { + userName: account.email + } + }, function (err) { + if (err) { + return callback(err); + } + + if (config.web.register.emailVerificationRequired) { + emailVerificationHandler(account); + } + res.locals.user = account; req.user = account; callback(null, account); - } + }); + }); }); } @@ -303,11 +375,9 @@ module.exports = function (req, res, next) { }); } - helpers.expandAccount(account, config.expand, logger, function (err, expandedAccount) { - req.user = expandedAccount; + req.user = account; + handleResponse(account, defaultCreatedHtmlResponse); - handleResponse(expandedAccount, defaultCreatedHtmlResponse); - }); }); break; diff --git a/lib/controllers/verify-email.js b/lib/controllers/verify-email.js index a6c736ea..e538f46a 100644 --- a/lib/controllers/verify-email.js +++ b/lib/controllers/verify-email.js @@ -1,5 +1,6 @@ 'use strict'; +var Account = require('stormpath/lib/resource/Account'); var forms = require('../forms'); var helpers = require('../helpers'); @@ -24,6 +25,7 @@ module.exports = function (req, res, next) { var application = req.app.get('stormpathApplication'); var client = req.app.get('stormpathClient'); var config = req.app.get('stormpathConfig'); + var emailVerificationHandler = config.emailVerificationHandler; var logger = req.app.get('stormpathLogger'); helpers.handleAcceptRequest(req, res, { @@ -40,7 +42,7 @@ module.exports = function (req, res, next) { }; } - application.resendVerificationEmail(options, function (err) { + application.resendVerificationEmail(options, function (err, account) { // Code 2016 means that an account does not exist for the given email // address. We don't want to leak information about the account list, // so allow this continue without error. @@ -49,8 +51,13 @@ module.exports = function (req, res, next) { return helpers.writeJsonError(res, err); } + if (account) { + emailVerificationHandler(new Account(account)); + } + res.end(); }); + break; case 'GET': @@ -99,7 +106,7 @@ module.exports = function (req, res, next) { }; } - application.resendVerificationEmail(options, function (err) { + application.resendVerificationEmail(options, function (err, account) { // Code 2016 means that an account does not exist for the given email // address. We don't want to leak information about the account // list, so allow this continue without error. @@ -108,6 +115,10 @@ module.exports = function (req, res, next) { return helpers.render(req, res, view, { error: err.message, form: form }); } + if (account) { + emailVerificationHandler(new Account(account)); + } + res.redirect(config.web.login.uri + '?status=unverified'); }); }, diff --git a/lib/stormpath.js b/lib/stormpath.js index 843d5c00..86565985 100644 --- a/lib/stormpath.js +++ b/lib/stormpath.js @@ -122,6 +122,12 @@ module.exports.init = function (app, opts) { config.expand.customData = true; } + config.emailVerificationHandler = config.emailVerificationHandler || function () {}; + + if (config.web.register.emailVerificationRequired) { + config.web.verifyEmail.enabled = true; + } + function localsMiddleware(req, res, next) { // Helper for getting the current URL. res.locals.url = req.protocol + '://' + helpers.getHost(req); @@ -227,9 +233,18 @@ module.exports.init = function (app, opts) { helpers.getFormViewModel('login', config, function () {}); helpers.getFormViewModel('register', config, function () {}); - // app.set('oktaApplication', results.application); - isClientReady = true; - app.emit('stormpath.ready'); + + + client.getApplication('/apps/' + config.application.id, function (err, application) { + if (err) { + throw err; + } + app.set('stormpathApplication', application); + isClientReady = true; + app.emit('stormpath.ready'); + }); + + }); diff --git a/okta-todo.md b/okta-todo.md index fa5f21a5..502dc8b4 100644 --- a/okta-todo.md +++ b/okta-todo.md @@ -3,14 +3,13 @@ Primary feature goals: [X] Get login working [X] Get token verification working [X] Get logout working -[ ] Get registration working +[X] Get registration working [ ] Social Login [ ] Group authorization -[ ] Email verification +[X] Email verification [ ] Password reset [ ] Client credentials authentication w/ keys as app user credentials (Basic Auth) [X] Remove dependencies on Stormpath configuration -[ ] Try to get current tests working, or rely on TCK? Todo tasks (discovered while implemented Primary goals): @@ -19,9 +18,11 @@ Todo tasks (discovered while implemented Primary goals): [ ] caching of jwks (can use HTTP response from .well-known) [ ] Caching of user resources. Note: need to invalidate this cache on logout (this has been removed and needs to be re-implemented) [ ] Remote token validation, in AccessTokenAuthenticator (right now it only does local validation) -[ ] authenticationResult should preserved, as much as possible [X] Transform the okta user object to the existing Stormpath account object, so that req.user.foo references will not break [ ] Invalid grant needs to be presented as "username or password" +[ ] Ensure that the two custom data import strategies will be populated onto the same custom data models and interfaces that we already have +[ ] Ensure that cache regions are still working +[ ] Ensure that account.save() and account.customData.save() will work like before # Configuration assumptions diff --git a/package.json b/package.json index d3efaa66..d8f59612 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "cookie-parser": "^1.3.5", "cookies": "^0.6.1", "deep-extend": "^0.4.1", + "dot-object": "^1.5.4", "express": "^4.13.4", "forms": "^1.1.4", "jade": "^1.11.0", diff --git a/test/controllers/test-email-verification.js b/test/controllers/test-email-verification.js index 82d24367..6c184caa 100644 --- a/test/controllers/test-email-verification.js +++ b/test/controllers/test-email-verification.js @@ -32,7 +32,7 @@ function assertInvalidEmailError(res) { assert($('.alert-danger').html().match(/Please enter a valid email address/)); } -describe('email verification', function () { +describe.only('email verification', function () { var expressApp; var stormpathApplication; @@ -45,26 +45,19 @@ describe('email verification', function () { stormpathApplication = app; - helpers.setEmailVerificationStatus(stormpathApplication, 'ENABLED', function (err) { - if (err) { - return done(err); - } - expressApp = helpers.createStormpathExpressApp({ - application: stormpathApplication, - web: { - register: { - enabled: true - } + expressApp = helpers.createOktaExpressApp({ + application: stormpathApplication, + web: { + register: { + enabled: true, + emailVerificationRequired: true } - }); - expressApp.on('stormpath.ready', done); + } }); - }); + expressApp.on('stormpath.ready', done); - }); + }); - after(function (done) { - helpers.destroyApplication(stormpathApplication, done); }); it('should show an "unverified" message after registration', function (done) { @@ -80,7 +73,7 @@ describe('email verification', function () { }); it('should allow me to re-send an email verification message and show the success on the login page', function (done) { - + // this will need to invoke an alternate handler var config = expressApp.get('stormpathConfig'); request(expressApp) .post(config.web.verifyEmail.uri) @@ -173,7 +166,7 @@ describe('email verification', function () { var client = expressApp.get('stormpathClient'); var config = expressApp.get('stormpathConfig'); - application.createAccount(helpers.newUser(), function (err, account) { + application.createAccount(helpers.newUser(), { activate: false }, function (err, account) { if (err) { return done(err); } @@ -189,10 +182,15 @@ describe('email verification', function () { } var token = account.emailVerificationToken.href.match(/\/([^\/]+)$/)[1]; - requestVerifyPage(expressApp, token) - .expect(302) - .expect('Location', config.web.login.uri + '?status=verified') - .end(done); + + setTimeout(function () { + // The search API, used to find an account with the given token, is not + // immediately consistent, so give it a moment to settle. + requestVerifyPage(expressApp, token) + .expect(302) + .expect('Location', config.web.login.uri + '?status=verified') + .end(done); + }, 1000); }); }); }); @@ -200,13 +198,16 @@ describe('email verification', function () { }); it('should be able to serve the form at a different uri', function (done) { - var app = helpers.createStormpathExpressApp({ + var app = helpers.createOktaExpressApp({ application: { href: stormpathApplication.href }, web:{ verifyEmail:{ uri: '/' + uuid() + }, + register: { + emailVerificationRequired: true } } }); @@ -227,8 +228,10 @@ describe('email verification', function () { before(function (done) { spaRootFixture = new SpaRootFixture({ - application: { - href: stormpathApplication.href + web: { + register: { + emailVerificationRequired: true + } } }); spaRootFixture.before(done); diff --git a/test/controllers/test-register.js b/test/controllers/test-register.js index 86a1ff36..15d83c8b 100644 --- a/test/controllers/test-register.js +++ b/test/controllers/test-register.js @@ -23,11 +23,8 @@ var SpaRootFixture = require('../fixtures/spa-root-fixture'); * * @param {object} stormpathApplication */ -function DefaultRegistrationFixture(stormpathApplication) { - this.expressApp = helpers.createStormpathExpressApp({ - application: { - href: stormpathApplication.href - }, +function DefaultRegistrationFixture() { + this.expressApp = helpers.createOktaExpressApp({ cacheOptions: { ttl: 0 }, @@ -98,6 +95,23 @@ DefaultRegistrationFixture.prototype.defaultJsonViewModel = { } }; +function VerificationRequiredFixture() { + this.expressApp = helpers.createOktaExpressApp({ + cacheOptions: { + ttl: 0 + }, + web: { + register: { + enabled: true, + emailVerificationRequired: true + } + } + }); + return this; +} + +VerificationRequiredFixture.prototype.defaultFormPost = DefaultRegistrationFixture.prototype.defaultFormPost; + /** * Creates an Express application and configures the register feature with the * surname and given name as optional (not required) fields. @@ -107,11 +121,8 @@ DefaultRegistrationFixture.prototype.defaultJsonViewModel = { * * @param {object} stormpathApplication */ -function NamesOptionalRegistrationFixture(stormpathApplication) { - this.expressApp = helpers.createStormpathExpressApp({ - application: { - href: stormpathApplication.href - }, +function NamesOptionalRegistrationFixture() { + this.expressApp = helpers.createOktaExpressApp({ cacheOptions: { ttl: 0 }, @@ -170,11 +181,8 @@ NamesOptionalRegistrationFixture.prototype.namesProvidedFormPost = function () { * * @param {object} stormpathApplication */ -function NamesDisabledRegistrationFixture(stormpathApplication) { - this.expressApp = helpers.createStormpathExpressApp({ - application: { - href: stormpathApplication.href - }, +function NamesDisabledRegistrationFixture() { + this.expressApp = helpers.createOktaExpressApp({ cacheOptions: { ttl: 0 }, @@ -217,11 +225,8 @@ NamesDisabledRegistrationFixture.prototype.defaultFormPost = function () { * * @param {object]} stormpathApplication */ -function CustomFieldRegistrationFixture(stormpathApplication) { - this.expressApp = helpers.createStormpathExpressApp({ - application: { - href: stormpathApplication.href - }, +function CustomFieldRegistrationFixture() { + this.expressApp = helpers.createOktaExpressApp({ cacheOptions: { ttl: 0 }, @@ -300,7 +305,7 @@ function assertCustomDataRegistration(fixture, formData, done) { return done(err); } - fixture.expressApp.get('stormpathApplication').getAccounts({ email: formData.email }, function (err, accounts) { + fixture.expressApp.get('stormpathClient').getAccounts({ q: formData.email }, function (err, accounts) { if (err) { return done(err); } @@ -325,7 +330,7 @@ function assertCustomDataRegistration(fixture, formData, done) { }; } -describe('register', function () { +describe.only('register', function () { var stormpathApplication; var stormpathClient; var customFieldRegistrationFixture; @@ -333,13 +338,6 @@ describe('register', function () { var namesDisabledRegistrationFixture; var namesOptionalRegistrationFixture; - var existingUserData = { - givenName: uuid.v4(), - surname: uuid.v4(), - email: 'robert+' + uuid.v4() + '@stormpath.com', - password: uuid.v4() + uuid.v4().toUpperCase() + '!' - }; - before(function (done) { stormpathClient = helpers.createClient(); helpers.createApplication(stormpathClient, function (err, app) { @@ -357,7 +355,9 @@ describe('register', function () { namesOptionalRegistrationFixture.expressApp.on('stormpath.ready', function () { namesDisabledRegistrationFixture = new NamesDisabledRegistrationFixture(stormpathApplication); namesDisabledRegistrationFixture.expressApp.on('stormpath.ready', function () { - app.createAccount(existingUserData, done); + // debugger + // app.createAccount(existingUserData, done); + done(); }); }); }); @@ -365,10 +365,6 @@ describe('register', function () { }); }); - after(function (done) { - helpers.destroyApplication(stormpathApplication, done); - }); - describe('by default', function () { it('should bind to GET /register by default', function (done) { @@ -397,7 +393,7 @@ describe('register', function () { describe('with a custom uri', function () { var app; before(function (done) { - app = helpers.createStormpathExpressApp({ + app = helpers.createOktaExpressApp({ application: { href: stormpathApplication.href }, @@ -426,23 +422,15 @@ describe('register', function () { }); }); + // need to add a case for when verification is disabled + describe('if email verification is enabled', function () { - var fixture; + var fixture = new VerificationRequiredFixture(stormpathApplication); - before(function (done) { - helpers.setEmailVerificationStatus(stormpathApplication, 'ENABLED', function (err) { - if (err) { - return done(err); - } - fixture = new DefaultRegistrationFixture(stormpathApplication); - fixture.expressApp.on('stormpath.ready', done); - }); - }); - - after(function (done) { - helpers.setEmailVerificationStatus(stormpathApplication, 'DISABLED', done); - }); + // before(function (done) { + // fixture.expressApp.on('stormpath.ready', done); + // }); it('should return the user to the login page with ?status=unverified', function (done) { @@ -488,6 +476,7 @@ describe('register', function () { assert.equal(json.account.surname, formData.surname); assert.equal(json.account.email, formData.email); assert.equal(json.account.emailVerificationToken, undefined); + assert.equal(json.account.emailVerificationStatus, 'UNVERIFIED'); done(); }); @@ -947,7 +936,7 @@ describe('register', function () { var app; before(function (done) { - app = helpers.createStormpathExpressApp({ + app = helpers.createOktaExpressApp({ application: { href: stormpathApplication.href }, @@ -1018,7 +1007,7 @@ describe('register', function () { return done(err); } - stormpathApplication.getAccounts({ email: formData.email }, function (err, accounts) { + stormpathClient.getAccounts({ q: formData.email }, function (err, accounts) { if (err) { return done(err); } @@ -1077,7 +1066,7 @@ describe('register', function () { return done(err); } - stormpathApplication.getAccounts({ email: formData.email }, function (err, accounts) { + stormpathClient.getAccounts({ q: formData.email }, function (err, accounts) { if (err) { return done(err); } diff --git a/util/okta-test-data.js b/util/okta-test-data.js index e4800dee..f230f269 100644 --- a/util/okta-test-data.js +++ b/util/okta-test-data.js @@ -135,6 +135,54 @@ var authorizationPolicy = { } }; +var userSchemaProperties = { + definitions: { + custom: { + id: '#Custom', + type: 'object', + properties: { + color: { + title: 'Favorite Color', + description: 'Used by registration tests', + type: 'string', + required: false, + minLength: 1, + maxLength: 64, + permissions: [] + }, + emailVerificationToken: { + title: 'Email Verification Token', + description: 'Can be sent to the user to verify their email address', + type: 'string', + required: false, + minLength: 1, + maxLength: 64, + permissions: [] + }, + emailVerificationStatus: { + title: 'Email Verification Status', + description: 'Indicates if the user has verified their email address', + type: 'string', + required: false, + minLength: 1, + maxLength: 32, + permissions: [] + }, + music: { + title: 'Music Preference', + description: 'Used by registration tests', + type: 'string', + required: false, + minLength: 1, + maxLength: 64, + permissions: [] + } + }, + required: [] + } + } +}; + function findAuthorizationServer(collection, next) { next(null, collection.items.filter(function (authorizationServer) { return authorizationServer.name === testAuthorizationServer.name; @@ -259,7 +307,8 @@ function main(client) { async.parallel({ authorizationServer: resolveAuthorizationServer.bind(null, client), application: resolveApplication.bind(null, client), - users: client.getResource.bind(client, '/users/', { filter: 'profile.email eq "' + testUser.profile.email + '"' }) + users: client.getResource.bind(client, '/users/', { filter: 'profile.email eq "' + testUser.profile.email + '"' }), + schema: client.createResource.bind(client, '/meta/schemas/user/default', userSchemaProperties) }, function (err, results) { if (err) { return exit(err);