From bbdc11dc637761e8cf401e879a8be0353c79f5a8 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 27 May 2016 15:43:47 -0400 Subject: [PATCH 001/178] OIDC Integration - WIP - WIP (fix standard warnings) - WIP (moving oidcConfig to config.json) - Add OIDC Issuer discovery link header on OPTIONS - Refactor OIDC client/handler code - Bump dep version, fix gitignore - Integrate Sign In w WebID page with OIDC login - Fix OIDC create user functionality - Fix storing redirectTo URL in session - Remove unneeded options obj (oidc.js) - WIP (create account using webid as OIDC _id) - Fix extraneous oidcIssuerHeader - WIP (fix response types) - Fix token() params - Fix authCallback() - WIP (switch to implicit workflow) - WIP (fix registration configs) - wip - Switch to authz code flow - Move createOIDCUser to oidc-rp-client (from identity-provider) - Implement OIDC /signout support - Do not create a Solid account folder if OIDC user creation fails - WIP - signin session / resume workflow - Signin api: send 500 error if oidc client not initialized - Remove invalid test - Implement user creation via api - Add username & pw auth logic to signin.js - Add bcrypt compilation Travis CI directives - Mount OIDC Identity Provider API into solid server routes - Initialize multi-rp client store collections - Change oidc packages to use github links - Disable the certificate test for the time being - Derive session cookie domain from root uri - Bump oidc-op-express dep - Output auth method at startup - Implement injected Provider logout() behavior - Remove legacy signout logic - Refactoring OIDC initialization from server config in create-app - Simplify createApp() - Move unit tests to their own test/unit/ folder - Refactor lib/api/authn/ files - Move tests to test/unit/ and test/integration/ - Implement CreateOidcAccountRequest and unit tests - Add OIDC account creation integration tests - Extract OidcManager code to external lib - Fix Provider Discover integration tests - Fix existing Login workflow integration test - Implement DiscoverProviderRequest, refactor - Extract login logic to LoginByPasswordRequest - Unit tests for LoginByPasswordRequest - Implement login via either username or webid - Add /login integration tests - Normalize webid if needed during provider discovery - Render Obtain Consent handlebars view - Save provider config on startup, install nyc / code coverage - Move options handler to before authentication or account api - Ensure local client registered on startup --- .gitignore | 13 +- .travis.yml | 8 + bin/lib/init.js | 7 + bin/lib/options.js | 5 +- lib/api/authn/index.js | 4 +- lib/api/authn/signin.js | 33 -- lib/api/authn/signout.js | 9 - lib/api/authn/webid-oidc.js | 166 +++++++ .../authn/webid-tls.js} | 9 +- lib/api/index.js | 2 + lib/capability-discovery.js | 4 +- lib/create-app.js | 151 +++++-- lib/debug.js | 1 + lib/handlers/error-pages.js | 39 +- lib/handlers/options.js | 22 +- lib/handlers/proxy.js | 2 +- lib/ldp-middleware.js | 8 - lib/ldp.js | 1 + lib/models/account-manager.js | 18 +- lib/models/oidc-manager.js | 120 +++++ lib/models/solid-host.js | 13 +- lib/models/user-account.js | 23 + lib/requests/create-account-request.js | 113 +++-- lib/requests/discover-provider-request.js | 230 ++++++++++ lib/requests/login-request.js | 278 ++++++++++++ lib/utils.js | 13 + package.json | 12 +- static/oidc/css/bootstrap-3.3.6.min.css | 6 + static/oidc/discover-provider.html | 38 ++ static/oidc/signed_out.html | 37 ++ test/api-accounts.js | 131 ------ test/create-account-request.js | 209 --------- test/integration/account-creation-oidc.js | 196 +++++++++ .../account-creation-tls.js} | 19 +- test/integration/account-manager.js | 121 ++++++ test/integration/account-template.js | 58 +++ test/{ => integration}/acl.js | 38 +- test/{ => integration}/api-messages.js | 35 +- test/integration/authentication-oidc.js | 198 +++++++++ .../{ => integration}/capability-discovery.js | 48 +- test/{ => integration}/errors.js | 10 +- test/{ => integration}/formats.js | 4 +- test/{ => integration}/http-copy.js | 20 +- test/{ => integration}/http.js | 33 +- test/{ => integration}/ldp.js | 26 +- test/{ => integration}/params.js | 16 +- test/{ => integration}/patch-2.js | 10 +- test/{ => integration}/patch.js | 10 +- test/{ => integration}/proxy.js | 4 +- test/{ => unit}/account-manager.js | 75 +--- test/{ => unit}/account-template.js | 51 +-- test/{ => unit}/acl-checker.js | 8 +- test/{ => unit}/add-cert-request.js | 10 +- test/unit/auth-oidc-handler.js | 42 ++ test/unit/create-account-request.js | 223 ++++++++++ test/unit/discover-provider-request.js | 86 ++++ test/{ => unit}/email-service.js | 8 +- test/unit/login-by-password-request.js | 411 ++++++++++++++++++ test/unit/oidc-manager.js | 33 ++ test/{ => unit}/solid-host.js | 17 +- test/{ => unit}/user-account.js | 17 +- test/{ => unit}/user-accounts-api.js | 14 +- test/{ => unit}/utils.js | 2 +- views/auth/consent.hbs | 33 ++ 64 files changed, 2838 insertions(+), 763 deletions(-) delete mode 100644 lib/api/authn/signin.js delete mode 100644 lib/api/authn/signout.js create mode 100644 lib/api/authn/webid-oidc.js rename lib/{handlers/authentication.js => api/authn/webid-tls.js} (90%) create mode 100644 lib/models/oidc-manager.js create mode 100644 lib/requests/discover-provider-request.js create mode 100644 lib/requests/login-request.js create mode 100644 static/oidc/css/bootstrap-3.3.6.min.css create mode 100644 static/oidc/discover-provider.html create mode 100644 static/oidc/signed_out.html delete mode 100644 test/api-accounts.js delete mode 100644 test/create-account-request.js create mode 100644 test/integration/account-creation-oidc.js rename test/{account-creation.js => integration/account-creation-tls.js} (94%) create mode 100644 test/integration/account-manager.js create mode 100644 test/integration/account-template.js rename test/{ => integration}/acl.js (96%) rename test/{ => integration}/api-messages.js (79%) create mode 100644 test/integration/authentication-oidc.js rename test/{ => integration}/capability-discovery.js (59%) rename test/{ => integration}/errors.js (84%) rename test/{ => integration}/formats.js (98%) rename test/{ => integration}/http-copy.js (78%) rename test/{ => integration}/http.js (95%) rename test/{ => integration}/ldp.js (87%) rename test/{ => integration}/params.js (89%) rename test/{ => integration}/patch-2.js (97%) rename test/{ => integration}/patch.js (96%) rename test/{ => integration}/proxy.js (97%) rename test/{ => unit}/account-manager.js (81%) rename test/{ => unit}/account-template.js (56%) rename test/{ => unit}/acl-checker.js (89%) rename test/{ => unit}/add-cert-request.js (90%) create mode 100644 test/unit/auth-oidc-handler.js create mode 100644 test/unit/create-account-request.js create mode 100644 test/unit/discover-provider-request.js rename test/{ => unit}/email-service.js (95%) create mode 100644 test/unit/login-by-password-request.js create mode 100644 test/unit/oidc-manager.js rename test/{ => unit}/solid-host.js (78%) rename test/{ => unit}/user-account.js (55%) rename test/{ => unit}/user-accounts-api.js (85%) rename test/{ => unit}/utils.js (97%) create mode 100644 views/auth/consent.hbs diff --git a/.gitignore b/.gitignore index cf5927687..ebebea82f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,12 @@ node_modules/ npm-debug.log config/account-template config/email-templates -/accounts -/profile -/inbox -/.acl -/config.json -/settings +accounts +profile +inbox +.acl +config.json +settings +db/ .nyc_output coverage diff --git a/.travis.yml b/.travis.yml index 32e58f9eb..30497b21f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,3 +11,11 @@ addons: - nic.localhost - tim.localhost - nicola.localhost + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - gcc-4.8 + - g++-4.8 +env: + - TRAVIS=travis CXX=g++-4.8 diff --git a/bin/lib/init.js b/bin/lib/init.js index ea1b10583..6b28fba8a 100644 --- a/bin/lib/init.js +++ b/bin/lib/init.js @@ -30,6 +30,13 @@ module.exports = function (program) { // Prompt to the user inquirer.prompt(questions) + // .then((answers) => { + // let store = new KVPFileStore() + // return store.createCollection('clients') + // .then(() => { + // return answers + // }) + // }) .then((answers) => { // setting email if (answers.useEmail) { diff --git a/bin/lib/options.js b/bin/lib/options.js index 9a47fdb33..abbf48927 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -52,10 +52,11 @@ module.exports = [ question: 'Select authentication strategy', type: 'list', choices: [ + 'WebID-OpenID Connect', 'WebID-TLS' ], - prompt: false, - default: 'WebID-TLS', + prompt: true, + default: 'WebID-OpenID Connect', filter: (value) => { if (value === 'WebID-TLS') return 'tls' }, diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index d3474e2da..cc13dd9fe 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -1,6 +1,6 @@ 'use strict' module.exports = { - signin: require('./signin'), - signout: require('./signout') + oidc: require('./webid-oidc'), + tls: require('./webid-tls') } diff --git a/lib/api/authn/signin.js b/lib/api/authn/signin.js deleted file mode 100644 index 01e88709f..000000000 --- a/lib/api/authn/signin.js +++ /dev/null @@ -1,33 +0,0 @@ -module.exports = signin - -const validUrl = require('valid-url') -const request = require('request') -const li = require('li') - -function signin () { - return (req, res, next) => { - if (!validUrl.isUri(req.body.webid)) { - return res.status(400).send('This is not a valid URI') - } - - request({ method: 'OPTIONS', uri: req.body.webid }, function (err, req) { - if (err) { - res.status(400).send('Did not find a valid endpoint') - return - } - if (!req.headers.link) { - res.status(400).send('The URI requested is not a valid endpoint') - return - } - - const linkHeaders = li.parse(req.headers.link) - console.log(linkHeaders) - if (!linkHeaders['oidc.issuer']) { - res.status(400).send('The URI requested is not a valid endpoint') - return - } - - res.redirect(linkHeaders['oidc.issuer']) - }) - } -} diff --git a/lib/api/authn/signout.js b/lib/api/authn/signout.js deleted file mode 100644 index 16ab7372c..000000000 --- a/lib/api/authn/signout.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = signout - -function signout () { - return (req, res, next) => { - req.session.userId = '' - req.session.identified = false - res.status(200).send() - } -} diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js new file mode 100644 index 000000000..23f5f023b --- /dev/null +++ b/lib/api/authn/webid-oidc.js @@ -0,0 +1,166 @@ +'use strict' +/** + * OIDC Relying Party API handler module. + */ + +const express = require('express') +const debug = require('../../debug') +const util = require('../../utils') +const error = require('../../http-error') +const bodyParser = require('body-parser').urlencoded({ extended: false }) + +const DiscoverProviderRequest = require('../../requests/discover-provider-request') +const { LoginByPasswordRequest } = require('../../requests/login-request') + +/** + * Returns a router with OIDC Relying Party and Identity Provider middleware: + * + * 1. Adds a Relying Party (RP) callback handler on '/api/oidc/rp/:issuer_id' + * 2. Sets up a static content handler for signin/signup apps + * 3. Adds a set of Identity Provider (OP) endpoints on '/' + * + * Usage (in create-app.js): + * + * ``` + * app.use('/', oidcHandler.api(oidc)) + * ``` + * @method middleware + * + * @param oidc {OidcManager} + * + * @return {Router} Express router + */ +function middleware (oidc) { + const router = express.Router('/') + + // User-facing Authentication API + router.get('/api/auth/discover', (req, res) => { + res.sendFile('discover-provider.html', { root: './static/oidc/' }) + }) + router.post('/api/auth/discover', bodyParser, discoverProvider) + + router.post(['/login', '/signin'], bodyParser, login) + + router.post('/logout', logout) + + // The relying party callback is called at the end of the OIDC signin process + router.get('/api/oidc/rp/:issuer_id', (req, res, next) => { + // Exchange authorization code for id token + authCodeFlowCallback(oidc, req) + // Redirect the user back to returnToUrl + .then(() => { resumeUserFlow(req, res) }) + .catch(next) + }) + + // Initialize the OIDC Identity Provider routes/api + // router.get('/.well-known/openid-configuration', discover.bind(provider)) + // router.get('/jwks', jwks.bind(provider)) + // router.post('/register', register.bind(provider)) + // router.get('/authorize', authorize.bind(provider)) + // router.post('/authorize', authorize.bind(provider)) + // router.post('/token', token.bind(provider)) + // router.get('/userinfo', userinfo.bind(provider)) + // router.get('/logout', logout.bind(provider)) + let oidcProviderApi = require('oidc-op-express')(oidc.provider) + router.use('/', oidcProviderApi) + + return router +} + +function discoverProvider (req, res, next) { + return DiscoverProviderRequest.handle(req, res) + .catch(error => { + error.status = error.status || 400 + next(error) + }) +} + +function login (req, res, next) { + return LoginByPasswordRequest.handle(req, res) + .catch(error => { + error.status = error.status || 400 + next(error) + }) +} + +function logout (req, res, next) { + req.session.userId = '' + req.session.identified = false + res.status(200).send() +} + +function authCodeFlowCallback (oidc, req) { + debug.oidc('in authCodeFlowCallback()') + + if (!req.params || !req.params.issuer_id) { + return Promise.reject(error(400, 'Invalid auth response uri - missing issuer id')) + } + + let issuer = getIssuerId(req) + + return oidc.clients.clientForIssuer(issuer) + .then(client => { + return validateResponse(client, req) + }) + .then(response => { + initSessionUserAuth(response, req) + }) + .catch((err) => { + debug.oidc(err) + throw error(400, err) + }) +} + +function getIssuerId (req = {}) { + return req.params && decodeURIComponent(req.params.issuer_id) +} + +function validateResponse (client, req) { + let url = util.fullUrlForReq(req) + return client.validateResponse(url, req.session) +} + +function initSessionUserAuth (authResponse, req) { + let webId = extractWebId(authResponse) + req.session.accessToken = authResponse.params.access_token + req.session.refreshToken = authResponse.params.refresh_token + req.session.userId = webId + req.session.identified = true +} + +function extractWebId (authResponse) { + return authResponse.decoded.payload.sub +} + +/** + * Redirects the user back to their original requested resource, at the end + * of the OIDC authentication process. + * @method resumeUserFlow + */ +function resumeUserFlow (req, res) { + debug.oidc('In resumeUserFlow handler:') + + if (req.session.returnToUrl) { + let returnToUrl = req.session.returnToUrl + // if (req.session.accessToken) { + // returnToUrl += '?access_token=' + req.session.accessToken + // } + debug.oidc(' Redirecting to ' + returnToUrl) + delete req.session.returnToUrl + return res.redirect(302, returnToUrl) + } + res.send('Resume User Flow (failed)') +} + +module.exports = { + middleware, + discoverProvider, + login, + logout, + extractWebId, + authCodeFlowCallback, + getIssuerId, + initSessionUserAuth, + resumeUserFlow, + validateResponse +} diff --git a/lib/handlers/authentication.js b/lib/api/authn/webid-tls.js similarity index 90% rename from lib/handlers/authentication.js rename to lib/api/authn/webid-tls.js index fef1df026..189e1869c 100644 --- a/lib/handlers/authentication.js +++ b/lib/api/authn/webid-tls.js @@ -1,8 +1,13 @@ module.exports = handler +module.exports.authenticate = authenticate var webid = require('webid/tls') -var debug = require('../debug').authentication -var error = require('../http-error') +var debug = require('../../debug').authentication +var error = require('../../http-error') + +function authenticate () { + return handler +} function handler (req, res, next) { var ldp = req.app.locals.ldp diff --git a/lib/api/index.js b/lib/api/index.js index 9e6b2a80b..c05519028 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -3,5 +3,7 @@ module.exports = { authn: require('./authn'), messages: require('./messages'), + oidc: require('./authn/webid-oidc'), + tls: require('./authn/webid-tls'), accounts: require('./accounts/user-accounts') } diff --git a/lib/capability-discovery.js b/lib/capability-discovery.js index 39b204982..10e3f3901 100644 --- a/lib/capability-discovery.js +++ b/lib/capability-discovery.js @@ -10,6 +10,8 @@ const serviceConfigDefaults = { 'accounts': { // 'changePassword': '/api/account/changePassword', // 'delete': '/api/accounts/delete', + + // Create new user (see IdentityProvider.post() in identity-provider.js) 'new': '/api/accounts/new', 'recover': '/api/accounts/recover', 'signin': '/api/accounts/signin', @@ -43,7 +45,7 @@ function capabilityDiscovery () { * @param next */ function serviceCapabilityDocument (serviceConfig) { - return (req, res, next) => { + return (req, res) => { // Add the server root url serviceConfig.root = util.uriBase(req) // TODO make sure we align with the rest // Add the 'apps' urls section diff --git a/lib/create-app.js b/lib/create-app.js index ec4039b19..89d994a9c 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -2,6 +2,7 @@ module.exports = createApp const express = require('express') const session = require('express-session') +const handlebars = require('express-handlebars') const uuid = require('uuid') const cors = require('cors') const LDP = require('./ldp') @@ -17,14 +18,16 @@ const AccountRecovery = require('./account-recovery') const capabilityDiscovery = require('./capability-discovery') const bodyParser = require('body-parser').urlencoded({ extended: false }) const API = require('./api') -const authentication = require('./handlers/authentication') const errorPages = require('./handlers/error-pages') +const OidcManager = require('./models/oidc-manager') +const defaults = require('../config/defaults') +const options = require('./handlers/options') -var corsSettings = cors({ +const corsSettings = cors({ methods: [ 'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE' ], - exposedHeaders: 'User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length', + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length', credentials: true, maxAge: 1728000, origin: true, @@ -33,51 +36,33 @@ var corsSettings = cors({ function createApp (argv = {}) { argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri }) + argv.auth = argv.auth || defaults.AUTH_METHOD argv.templates = initTemplates() let ldp = new LDP(argv) let app = express() - app.use(corsSettings) - - app.options('*', (req, res, next) => { - res.status(204) - next() - }) - - // Setting options as local variable - app.locals.ldp = ldp - app.locals.appUrls = argv.apps // used for service capability discovery - let multiUser = argv.idp - - if (argv.email && argv.email.host) { - app.locals.emailService = new EmailService(argv.templates.email, argv.email) - } + initAppLocals(app, argv, ldp) - // Set X-Powered-By - app.use(function (req, res, next) { - res.set('X-Powered-By', 'solid-server') - next() - }) - - // Set default Allow methods - app.use(function (req, res, next) { - res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') - next() - }) - - app.use('/', capabilityDiscovery()) + initHeaders(app) // Use session cookies let useSecureCookies = argv.webid // argv.webid forces https and secure cookies app.use(session(sessionSettings(useSecureCookies, argv.host))) // Adding proxy - if (ldp.proxy) { + if (argv.proxy) { proxy(app, ldp.proxy) } - if (ldp.webid) { + // Options handler + app.options('/*', options) + + if (argv.apiApps) { + app.use('/api/apps', express.static(argv.apiApps)) + } + + if (argv.webid) { var accountRecovery = AccountRecovery({ redirect: '/' }) // adds GET /api/accounts/recover // adds POST /api/accounts/recover @@ -90,25 +75,21 @@ function createApp (argv = {}) { host: argv.host, accountTemplatePath: argv.templates.account, store: ldp, - multiUser + multiUser: argv.idp }) + app.locals.accountManager = accountManager // Account Management API (create account, new cert) app.use('/', API.accounts.middleware(accountManager)) - // Authentication API (login/logout) - app.post('/api/accounts/signin', bodyParser, API.authn.signin()) - app.post('/api/accounts/signout', API.authn.signout()) + // Set up authentication-related API endpoints and app.locals + initAuthentication(argv, app) // Messaging API - app.post('/api/messages', authentication, bodyParser, API.messages.send()) + app.post('/api/messages', bodyParser, API.messages.send()) } - if (argv.apiApps) { - app.use('/api/apps', express.static(argv.apiApps)) - } - - if (ldp.idp) { + if (argv.idp) { app.use(vhost('*', LdpMiddleware(corsSettings))) } @@ -163,6 +144,90 @@ function ensureTemplateCopiedTo (defaultTemplateDir, configTemplateDir) { return configTemplatePath } +/** + * Initializes `app.locals` parameters for downstream use (typically by route + * handlers). + * + * @param app {Function} Express.js app instance + * @param argv {Object} Config options hashmap + * @param ldp {LDP} + */ +function initAppLocals (app, argv, ldp) { + app.locals.ldp = ldp + app.locals.appUrls = argv.apps // used for service capability discovery + app.locals.host = argv.host + app.locals.authMethod = argv.auth + + if (argv.email && argv.email.host) { + app.locals.emailService = new EmailService(argv.templates.email, argv.email) + } +} + +/** + * Sets up authentication-related routes and handlers for the app. + * + * @param argv {Object} Config options hashmap + * @param app {Function} Express.js app instance + */ +function initAuthentication (argv, app) { + let authMethod = argv.auth + + switch (authMethod) { + case 'tls': + // Enforce authentication with WebID-TLS on all LDP routes + app.use('/', API.tls.authenticate()) + break + case 'oidc': + let oidc = OidcManager.fromServerConfig(argv) + app.locals.oidc = oidc + + // This is where the OIDC-enabled signup/signin apps live + app.use('/', express.static(path.join(__dirname, '../static/oidc'))) + initAuthTemplates(app) + + // Initialize the WebId-OIDC authentication routes/api, including: + // user-facing Solid endpoints (/login, /logout, /api/auth/discover) + // and OIDC-specific ones + app.use('/', API.oidc.middleware(oidc)) + + // Enforce authentication with WebID-OIDC on all LDP routes + app.use('/', oidc.rs.authenticate()) + break + default: + throw new TypeError('Unsupported authentication scheme') + } +} + +/** + * Sets up Handlebars to be used for auth-related views. + * + * @param app {Function} Express.js app instance + */ +function initAuthTemplates (app) { + app.set('views', path.join(__dirname, '../views')) + app.engine('.hbs', handlebars({ extname: '.hbs' })) + app.set('view engine', '.hbs') +} + +/** + * Sets up headers common to all Solid requests (CORS-related, Allow, etc). + * + * @param app {Function} Express.js app instance + */ +function initHeaders (app) { + app.use(corsSettings) + + app.use((req, res, next) => { + // Set X-Powered-By + res.set('X-Powered-By', 'solid-server') + // Set default Allow methods + res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') + next() + }) + + app.use('/', capabilityDiscovery()) +} + /** * Returns a settings object for Express.js sessions. * diff --git a/lib/debug.js b/lib/debug.js index d28e50691..2585f1fa6 100644 --- a/lib/debug.js +++ b/lib/debug.js @@ -12,3 +12,4 @@ exports.subscription = debug('solid:subscription') exports.container = debug('solid:container') exports.accounts = debug('solid:accounts') exports.ldp = debug('solid:ldp') +exports.oidc = debug('solid:oidc') diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index 18a5f3bc4..71d26ac18 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -2,6 +2,7 @@ module.exports = handler var debug = require('../debug').server var fs = require('fs') +var util = require('../utils') function handler (err, req, res, next) { debug('Error page because of ' + err.message) @@ -17,9 +18,18 @@ function handler (err, req, res, next) { // If noErrorPages is set, // then use built-in express default error handler if (ldp.noErrorPages) { - return res + if (err.status === 401 && + req.accepts('text/html') && + ldp.auth === 'oidc') { + debug('On error pages redirect on 401') + res.status(err.status) + redirectToLogin(req, res, next) + return + } + res .status(err.status) .send(err.message + '\n' || '') + return } // Check if error page exists @@ -36,3 +46,30 @@ function handler (err, req, res, next) { res.send(text) }) } + +function redirectBody (url) { + return ` + + + +Redirecting... +If you are not redirected automatically, follow the link to login +` +} + +function redirectToLogin (req, res) { + res.header('Content-Type', 'text/html') + var currentUrl = util.fullUrlForReq(req) + req.session.returnToUrl = currentUrl + let locals = req.app.locals + let loginUrl = locals.host.serverUri + '/api/auth/discover?returnToUrl=' + + currentUrl + debug('Redirecting to login: ' + loginUrl) + + var body = redirectBody(loginUrl) + res.send(body) +} diff --git a/lib/handlers/options.js b/lib/handlers/options.js index 792782136..20b19eab3 100644 --- a/lib/handlers/options.js +++ b/lib/handlers/options.js @@ -4,8 +4,28 @@ const utils = require('../utils') module.exports = handler function handler (req, res, next) { + linkServiceEndpoint(req, res) + linkAuthProvider(req, res) + linkSparqlEndpoint(res) + + res.status(204) + + next() +} + +function linkAuthProvider (req, res) { + let locals = req.app.locals + if (locals.authMethod === 'oidc') { + let oidcProviderUri = locals.host.serverUri + addLink(res, oidcProviderUri, 'oidc.provider') + } +} + +function linkServiceEndpoint (req, res) { let serviceEndpoint = `${utils.uriBase(req)}/.well-known/solid` addLink(res, serviceEndpoint, 'service') +} + +function linkSparqlEndpoint (res) { res.header('Accept-Patch', 'application/sparql-update') - next() } diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js index e4049e9a4..d7893840d 100644 --- a/lib/handlers/proxy.js +++ b/lib/handlers/proxy.js @@ -15,7 +15,7 @@ function addProxy (app, path) { path, cors({ methods: ['GET'], - exposedHeaders: 'User, Location, Link, Vary, Last-Modified, Content-Length', + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, Content-Length', maxAge: 1728000, origin: true }), diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js index 88dd6475c..d500b741c 100644 --- a/lib/ldp-middleware.js +++ b/lib/ldp-middleware.js @@ -3,12 +3,10 @@ module.exports = LdpMiddleware var express = require('express') var header = require('./header') var acl = require('./handlers/allow') -var authentication = require('./handlers/authentication') var get = require('./handlers/get') var post = require('./handlers/post') var put = require('./handlers/put') var del = require('./handlers/delete') -var options = require('./handlers/options') var patch = require('./handlers/patch') var index = require('./handlers/index') var copy = require('./handlers/copy') @@ -27,18 +25,12 @@ function LdpMiddleware (corsSettings) { router.use(corsSettings) } - router.use('/*', authentication) router.copy('/*', acl.allow('Write'), copy) router.get('/*', index, acl.allow('Read'), get) router.post('/*', acl.allow('Append'), post) router.patch('/*', acl.allow('Write'), patch) router.put('/*', acl.allow('Write'), put) router.delete('/*', acl.allow('Write'), del) - router.options('/*', options) - - // TODO: in the process of being deprecated - // Convert json-ld and nquads to turtle - // router.use('/*', parse.parseHandler) return router } diff --git a/lib/ldp.js b/lib/ldp.js index 58a44962d..8331188df 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -83,6 +83,7 @@ class LDP { this.proxy = '/' + this.proxy } + debug.settings('Auth method: ' + this.auth) debug.settings('Suffix Acl: ' + this.suffixAcl) debug.settings('Suffix Meta: ' + this.suffixMeta) debug.settings('Filesystem Root: ' + this.root) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 5bbcc4f68..26443184c 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -209,6 +209,10 @@ class AccountManager { * @return {Promise} */ addCertKeyToProfile (certificate, userAccount) { + if (!certificate) { + throw new TypeError('Cannot add empty certificate to user profile') + } + return this.getProfileGraphFor(userAccount) .then(profileGraph => { return this.addCertKeyToGraph(certificate, profileGraph) @@ -302,7 +306,14 @@ class AccountManager { * Creates and returns a `UserAccount` instance from submitted user data * (typically something like `req.body`, from a signup form). * - * @param userData {Object} Options hashmap, like `req.body` + * @param userData {Object} Options hashmap, like `req.body`. + * Either a `username` or a `webid` property is required. + * + * @param [userData.username] {string} + * @param [uesrData.webid] {string} + * + * @param [userData.email] {string} + * @param [userData.name] {string} * * @throws {Error} (via `accountWebIdFor()`) If in multiUser mode and no * username passed @@ -313,9 +324,10 @@ class AccountManager { let userConfig = { username: userData.username, email: userData.email, - name: userData.name + name: userData.name, + webId: userData.webid || this.accountWebIdFor(userData.username) } - userConfig.webId = userData.webid || this.accountWebIdFor(userData.username) + return UserAccount.from(userConfig) } diff --git a/lib/models/oidc-manager.js b/lib/models/oidc-manager.js new file mode 100644 index 000000000..343c5adc9 --- /dev/null +++ b/lib/models/oidc-manager.js @@ -0,0 +1,120 @@ +'use strict' + +const url = require('url') +const debug = require('./../debug').oidc + +const OidcManager = require('oidc-auth-manager') + +/** + * Returns an instance of the OIDC Authentication Manager, initialized from + * argv / config.json server parameters. + * + * @param argv {Object} Config hashmap + * + * @param argv.host {SolidHost} Initialized SolidHost instance, including + * `serverUri`. + * + * @param [argv.dbPath='./db/oidc'] {string} Path to the auth-related storage + * directory (users, tokens, client registrations, etc, will be stored there). + * + * @param argv.saltRounds {number} Number of bcrypt password salt rounds + * + * @return {OidcManager} Initialized instance, includes a UserStore, + * OIDC Clients store, a Resource Authenticator, and an OIDC Provider. + */ +function fromServerConfig (argv) { + let providerUri = argv.host.serverUri + if (!providerUri) { + throw new Error('Host with serverUri required for auth initialization') + } + + let authCallbackUri = url.resolve(providerUri, '/api/oidc/rp') + let postLogoutUri = url.resolve(providerUri, '/signed_out.html') + + let options = { + providerUri, + dbPath: argv.dbPath || './db/oidc', + authCallbackUri, + postLogoutUri, + saltRounds: argv.saltRounds, + host: { authenticate, obtainConsent, logout } + } + let oidc = OidcManager.from(options) + oidc.initialize() + .then(() => { + oidc.saveProviderConfig() + return oidc.clients.clientForIssuer(providerUri) + }) + .then(localClient => { + console.log('Local RP client initialized') + oidc.localRp = localClient + }) + + return oidc +} + +// This gets called from OIDC Provider's /authorize endpoint +function authenticate (authRequest) { + let session = authRequest.req.session + debug('AUTHENTICATE injected method') + + if (session.identified && session.userId) { + debug('User webId found in session: ', session.userId) + + authRequest.subject = { + _id: session.userId // put webId into the IDToken's subject claim + } + } else { + // User not authenticated, send them to login + debug('User not authenticated, sending to /login') + + let loginUrl = url.parse('/login') + loginUrl.query = authRequest.req.query + loginUrl = url.format(loginUrl) + authRequest.subject = null + authRequest.res.redirect(loginUrl) + } + return authRequest +} + +function obtainConsent (authRequest) { + if (authRequest.subject) { + let { req, res } = authRequest + + if (req.body.consent) { + authRequest.consent = true + authRequest.scope = authRequest.params.scope + debug('OBTAINED CONSENT') + } else { + let params = req.query['client_id'] ? req.query : req.body + + // let clientId = params['client_id'] + // let locals = req.app.locals + // let clientStore = locals.oidc.clients + + res.render('auth/consent', params) + authRequest.headersSent = true + } + } + + return authRequest +} + +function logout (logoutRequest) { + let req = logoutRequest.req + req.session.accessToken = '' + req.session.refreshToken = '' + // req.session.issuer = '' + req.session.userId = '' + req.session.identified = false + // Inject post_logout_redirect_uri here? (If Accept: text/html) + debug('LOGOUT behavior') + return logoutRequest +} + +module.exports = { + fromServerConfig, + authenticate, + obtainConsent, + logout +} diff --git a/lib/models/solid-host.js b/lib/models/solid-host.js index 972d75ca1..40abefea8 100644 --- a/lib/models/solid-host.js +++ b/lib/models/solid-host.js @@ -14,7 +14,8 @@ class SolidHost { * @constructor * @param [options={}] * @param [options.port] {number} - * @param [options.serverUri] {string} + * @param [options.serverUri] {string} Fully qualified URI that this Solid + * server is listening on, e.g. `https://databox.me` */ constructor (options = {}) { this.port = options.port || defaults.DEFAULT_PORT @@ -61,6 +62,16 @@ class SolidHost { return this.parsedUri.protocol + '//' + accountName + '.' + this.host } + /** + * Returns the /authorize endpoint URL object (used in login requests, etc). + * + * @return {URL} + */ + get authEndpoint () { + let authUrl = url.resolve(this.serverUri, '/authorize') + return url.parse(authUrl) + } + /** * Returns a cookie domain, based on the current host's serverUri. * diff --git a/lib/models/user-account.js b/lib/models/user-account.js index adf13d82c..dc47a1e0b 100644 --- a/lib/models/user-account.js +++ b/lib/models/user-account.js @@ -41,6 +41,29 @@ class UserAccount { return this.name || this.username || this.email || 'Solid account' } + /** + * Returns the id key for the user account (for use with the user store, for + * example), consisting of the WebID URI minus the protocol and slashes. + * Usage: + * + * ``` + * userAccount.webId = 'https://alice.example.com/profile/card#me' + * userAccount.id // -> 'alice.example.com/profile/card#me' + * ``` + * + * @return {string} + */ + get id () { + if (!this.webId) { return null } + + let parsed = url.parse(this.webId) + let id = parsed.host + parsed.pathname + if (parsed.hash) { + id += parsed.hash + } + return id + } + /** * Returns the URI of the WebID Profile for this account. * Usage: diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index 84ea25fe8..5fe9b0e58 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -39,12 +39,18 @@ class CreateAccountRequest { * @param res * @param accountManager {AccountManager} * - * @throws {TypeError} If required parameters are missing (`userAccountFrom()`), - * or it encounters an unsupported authentication scheme. + * @throws {TypeError} If required parameters are missing (via + * `userAccountFrom()`), or it encounters an unsupported authentication + * scheme. * * @return {CreateAccountRequest|CreateTlsAccountRequest} */ static fromParams (req, res, accountManager) { + if (!req.app || !req.app.locals) { + throw new TypeError('Missing req.app.local params') + } + + let authMethod = req.app.locals.authMethod let userAccount = accountManager.userAccountFrom(req.body) let options = { @@ -54,7 +60,12 @@ class CreateAccountRequest { response: res } - switch (accountManager.authMethod) { + switch (authMethod) { + case 'oidc': + options.password = req.body.password + let locals = req.app.locals + options.userStore = locals.oidc.users + return new CreateOidcAccountRequest(options) case 'tls': options.spkac = req.body.spkac return new CreateTlsAccountRequest(options) @@ -76,8 +87,8 @@ class CreateAccountRequest { return Promise.resolve(userAccount) .then(this.cancelIfAccountExists.bind(this)) - .then(this.generateCredentials.bind(this)) .then(this.createAccountStorage.bind(this)) + .then(this.saveCredentialsFor.bind(this)) .then(this.initSession.bind(this)) .then(this.sendResponse.bind(this)) .then(userAccount => { @@ -123,7 +134,7 @@ class CreateAccountRequest { * @param userAccount {UserAccount} Instance of the account to be created * * @throws {Error} If errors were encountering while creating new account - * resources, or saving generated credentials. + * resources. * * @return {Promise} Chainable */ @@ -133,11 +144,6 @@ class CreateAccountRequest { error.message = 'Error creating account storage: ' + error.message throw error }) - .then(() => { - // Once the account folder has been initialized, - // save the public keys or other generated credentials to the profile - return this.saveCredentialsFor(userAccount) - }) .then(() => { debug('Account storage resources created') return userAccount @@ -163,34 +169,71 @@ class CreateAccountRequest { } /** - * Models a Create Account request for a server using WebID-TLS as primary - * authentication mode. Handles generating and saving a TLS certificate, etc. + * Models a Create Account request for a server using WebID-OIDC (OpenID Connect) + * as a primary authentication mode. Handles saving user credentials to the + * `UserStore`, etc. * - * @class CreateTlsAccountRequest + * @class CreateOidcAccountRequest * @extends CreateAccountRequest */ -class CreateTlsAccountRequest extends CreateAccountRequest { +class CreateOidcAccountRequest extends CreateAccountRequest { /** * @constructor * * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring - * @param [options.spkac] {string} + * @param [options.password] {string} Password, as entered by the user at signup */ constructor (options = {}) { + if (!options.password) { + let error = new TypeError('Password required to create an account') + error.status = 400 + throw error + } + super(options) - this.spkac = options.spkac - this.certificate = null + this.password = options.password + this.userStore = options.userStore } /** - * Generates required user credentials (WebID-TLS certificate, etc). + * Generate salted password hash, etc. * * @param userAccount {UserAccount} * - * @return {Promise} Chainable + * @return {Promise} */ - generateCredentials (userAccount) { - return this.generateTlsCertificate(userAccount) + saveCredentialsFor (userAccount) { + return this.userStore.createUser(userAccount, this.password) + .then(() => { + debug('User credentials stored') + return userAccount + }) + } + + sendResponse (userAccount) { + let res = this.response + res.sendStatus(201) + } +} + +/** + * Models a Create Account request for a server using WebID-TLS as primary + * authentication mode. Handles generating and saving a TLS certificate, etc. + * + * @class CreateTlsAccountRequest + * @extends CreateAccountRequest + */ +class CreateTlsAccountRequest extends CreateAccountRequest { + /** + * @constructor + * + * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring + * @param [options.spkac] {string} + */ + constructor (options = {}) { + super(options) + this.spkac = options.spkac + this.certificate = null } /** @@ -201,8 +244,8 @@ class CreateTlsAccountRequest extends CreateAccountRequest { * @param userAccount {UserAccount} * @param userAccount.webId {string} An agent's WebID URI * - * @throws {Error} HTTP 400 error if errors were encountering during certificate - * generation. + * @throws {Error} HTTP 400 error if errors were encountering during + * certificate generation. * * @return {Promise} Chainable */ @@ -231,22 +274,28 @@ class CreateTlsAccountRequest extends CreateAccountRequest { } /** - * If a WebID-TLS certificate was generated, saves it to the user's profile + * Generates a WebID-TLS certificate and saves it to the user's profile * graph. * * @param userAccount {UserAccount} * - * @return {Promise} + * @return {Promise} Chainable */ saveCredentialsFor (userAccount) { - if (!this.certificate) { - return Promise.resolve(null) - } - - return this.accountManager - .addCertKeyToProfile(this.certificate, userAccount) + return this.generateTlsCertificate(userAccount) + .then(userAccount => { + if (this.certificate) { + return this.accountManager + .addCertKeyToProfile(this.certificate, userAccount) + .then(() => { + debug('Saved generated WebID-TLS certificate to profile') + }) + } else { + debug('No certificate generated, no need to save to profile') + } + }) .then(() => { - debug('Saved generated WebID-TLS certificate to profile') + return userAccount }) } diff --git a/lib/requests/discover-provider-request.js b/lib/requests/discover-provider-request.js new file mode 100644 index 000000000..ae6d4804a --- /dev/null +++ b/lib/requests/discover-provider-request.js @@ -0,0 +1,230 @@ +'use strict' + +const validUrl = require('valid-url') +const fetch = require('node-fetch') +const li = require('li') + +class DiscoverProviderRequest { + /** + * @constructor + * + * @param [options={}] + * @param [options.webId] {string} + * @param [options.oidcManager] {OidcManager} + * @param [options.response] {HttpResponse} + */ + constructor (options = {}) { + this.webId = options.webId + this.oidcManager = options.oidcManager + this.response = options.response + this.session = options.session + } + + /** + * Validates the request and throws an error if invalid. + * + * @throws {TypeError} HTTP 400 if required parameters are missing + */ + validate () { + if (!this.webId) { + let error = new TypeError('No webid is given for Provider Discovery') + error.statusCode = 400 + throw error + } + + if (!validUrl.isUri(this.webId)) { + let error = new TypeError('Invalid webid given for Provider Discovery') + error.statusCode = 400 + throw error + } + + if (!this.oidcManager) { + let error = new Error('OIDC multi-rp client not initialized') + error.statusCode = 500 + throw error + } + } + + /** + * Factory method, creates and returns an initialized and validated instance + * of DiscoverProviderRequest from a submitted POST form. + * + * @param [req] {HttpRequest} + * @param [req.body.webid] {string} + * @param [res] {HttpResponse} + * + * @throws {TypeError} HTTP 400 if required parameters are missing + * + * @return {DiscoverProviderRequest} + */ + static fromParams (req = {}, res = {}) { + let body = req.body || {} + let webId = DiscoverProviderRequest.normalizeUri(body.webid) + + let oidcManager + if (req.app && req.app.locals) { + oidcManager = req.app.locals.oidc + } + + let options = { webId, oidcManager, response: res, session: req.session } + + let request = new DiscoverProviderRequest(options) + request.validate() + + return request + } + + /** + * Attempts to return a normalized URI by prepending `https://` to a given + * value, if a protocol is missing. + * + * @param uri {string} + * + * @return {string} + */ + static normalizeUri (uri) { + if (!uri) { + return uri + } + + if (!uri.startsWith('http')) { + uri = 'https://' + uri + } + + return uri + } + + /** + * Handles the Discover Provider request. Usage: + * + * ``` + * app.post('/api/auth/discover', bodyParser, (req, res, next) => { + * return DiscoverProviderRequest.handle(req, res) + * .catch(next) + * }) + * ``` + * + * @param req + * @param res + * @throws {Error} + * @return {Promise} + */ + static handle (req, res) { + let request + + try { + request = DiscoverProviderRequest.fromParams(req, res) + } catch (error) { + return Promise.reject(error) + } + + return request.discoverProvider() + } + + /** + * Performs provider discovery by determining a user's preferred provider uri, + * constructing an authentication url for that provider, and redirecting the + * user to it. + * + * @throws {Error} + * + * @return {Promise} + */ + discoverProvider () { + return this.fetchProviderUri() + .then(this.authUrlFor.bind(this)) + .then(authUrl => { + this.response.redirect(authUrl) + }) + } + + /** + * Determines the preferred provider for the given Web ID by making an http + * OPTIONS request to it and parsing the `oidc.provider` Link header. + * + * @throws {Error} If unable to reach the Web ID URI, or if no valid + * `oidc.provider` was advertised. + * + * @return {Promise} + */ + fetchProviderUri () { + let uri = this.webId + + return this.requestOptions(uri) + .then(this.parseProviderLink) + .then(providerUri => { + this.validateProviderUri(providerUri) // Throw an error if invalid + + return providerUri + }) + } + + /** + * Performs an HTTP OPTIONS call to a given uri, and returns the response + * headers. + * + * @param uri {string} Typically a Web ID or profile uri + * + * @return {Promise} + */ + requestOptions (uri) { + return fetch(uri, { method: 'OPTIONS' }) + .then(response => { + return response.headers + }) + .catch(() => { + let error = new Error(`Provider not found at uri: ${uri}`) + error.statusCode = 400 + throw error + }) + } + + /** + * Returns the contents of the `oidc.provider` Link rel header. + * + * @param headers {Headers} Response headers from an OPTIONS call + * + * @return {string} + */ + parseProviderLink (headers) { + let links = li.parse(headers.get('link')) || {} + + return links['oidc.provider'] + } + + /** + * Validates a preferred provider uri (makes sure it's a well-formed URI). + * + * @param provider {string} Identity provider URI + * + * @throws {Error} If the URI is invalid + */ + validateProviderUri (provider) { + if (!provider) { + let error = new Error(`oidc.provider not advertised for ${this.webId}`) + error.statusCode = 400 + throw error + } + + if (!validUrl.isUri(provider)) { + let error = new Error(`oidc.provider for ${this.webId} is not a valid URI: ${provider}`) + error.statusCode = 400 + throw error + } + } + + /** + * Constructs the OIDC authorization URL for a given provider. + * + * @param provider {string} Identity provider URI + * + * @return {Promise} + */ + authUrlFor (provider) { + let multiRpClient = this.oidcManager.clients + + return multiRpClient.authUrlForIssuer(provider, this.session) + } +} + +module.exports = DiscoverProviderRequest diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js new file mode 100644 index 000000000..e5f687ee3 --- /dev/null +++ b/lib/requests/login-request.js @@ -0,0 +1,278 @@ +'use strict' + +const url = require('url') +const validUrl = require('valid-url') + +const debug = require('./../debug').authentication +const UserAccount = require('../models/user-account') + +/** + * Models a Login request, a POST submit from a Login form with a username and + * password. Used with authMethod of 'oidc'. + * + * For usage example, see `handle()` docstring, below. + */ +class LoginByPasswordRequest { + /** + * @constructor + * @param [options={}] {Object} + * + * @param [options.username] {string} Unique identifier submitted by user + * from the Login form. Can be one of: + * - An account name (e.g. 'alice'), if server is in Multi-User mode + * - A WebID URI (e.g. 'https://alice.example.com/#me') + * + * @param [options.password] {string} Plaintext password as submitted by user + * + * @param [options.response] {ServerResponse} middleware `res` object + * @param [options.session] {Session} req.session + * @param [options.userStore] {UserStore} + * @param [options.accountManager] {AccountManager} + * @param [options.authQueryParams] {Object} Key/value hashmap of parsed query + * parameters that will be passed through to the /authorize endpoint. + */ + constructor (options = {}) { + this.username = options.username + this.password = options.password + this.response = options.response + this.session = options.session || {} + this.userStore = options.userStore + this.accountManager = options.accountManager + this.authQueryParams = options.authQueryParams || {} + } + + /** + * Handles a Login request on behalf of a middleware handler. Usage: + * + * ``` + * app.post('/login', (req, res, next) = { + * LoginByPasswordRequest.handle(req, res) + * .catch(next) + * }) + * ``` + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * + * @throws {Error} HTTP 400 error if required parameters are missing, or + * if the user is not found or the password does not match. + * + * @return {Promise} + */ + static handle (req, res) { + let request + + try { + request = LoginByPasswordRequest.fromParams(req, res) + } catch (error) { + return Promise.reject(error) + } + + return LoginByPasswordRequest.login(request) + } + + /** + * Factory method, returns an initialized instance of LoginByPasswordRequest + * from an incoming http request. + * + * @param [req={}] {IncomingRequest} + * @param [res={}] {ServerResponse} + * + * @return {LoginByPasswordRequest} + */ + static fromParams (req = {}, res = {}) { + let body = req.body || {} + + let userStore, accountManager + + if (req.app && req.app.locals) { + let locals = req.app.locals + + if (locals.oidc) { + userStore = locals.oidc.users + } + + accountManager = locals.accountManager + } + + let options = { + username: body.username, + password: body.password, + response: res, + session: req.session, + userStore, + accountManager, + authQueryParams: LoginByPasswordRequest.extractQueryParams(body) + } + + return new LoginByPasswordRequest(options) + } + + /** + * Performs the login operation -- validates required parameters, loads the + * appropriate user, inits the session if passwords match, and redirects the + * user to continue their OIDC auth flow. + * + * @param request {LoginByPasswordRequest} + * + * @throws {Error} HTTP 400 error if required parameters are missing, or + * if the user is not found or the password does not match. + * + * @return {Promise} + */ + static login (request) { + return Promise.resolve() + .then(() => { + request.validate() + + return request.findValidUser() + }) + .then(validUser => { + request.initUserSession(validUser) + request.redirectToAuthorize() + }) + } + + /** + * Initializes query params required by OIDC work flow from the request body. + * Only authorized params are loaded, all others are discarded. + * + * @param body {Object} Key/value hashmap, ie `req.body`. + * + * @return {Object} + */ + static extractQueryParams (body) { + let extracted = {} + + let paramKeys = LoginByPasswordRequest.AUTH_QUERY_PARAMS + + for (let p of paramKeys) { + extracted[p] = body[p] + } + + return extracted + } + + /** + * Validates the Login request (makes sure required parameters are present), + * and throws an error if not. + * + * @throws {TypeError} If missing required params + */ + validate () { + let error + + if (!this.username) { + error = new TypeError('Username required') + error.statusCode = 400 + throw error + } + + if (!this.password) { + error = new TypeError('Password required') + error.statusCode = 400 + throw error + } + } + + /** + * Loads a user from the user store, and if one is found and the + * password matches, returns a `UserAccount` instance for that user. + * + * @throws {TypeError} If + * + * @return {Promise} + */ + findValidUser () { + let error + let userOptions + + if (validUrl.isUri(this.username)) { + // A WebID URI was entered into the username field + userOptions = { webid: this.username } + } else { + // A regular username + userOptions = { username: this.username } + } + + return Promise.resolve() + .then(() => { + let user = this.accountManager.userAccountFrom(userOptions) + + debug(`Attempting to login user: ${user.id}`) + + return this.userStore.findUser(user.id) + }) + .then(foundUser => { + if (!foundUser) { + error = new TypeError('No user found for that username') + error.statusCode = 400 + throw error + } + + return this.userStore.matchPassword(foundUser, this.password) + }) + .then(validUser => { + if (!validUser) { + error = new TypeError('User found but no password found') + error.statusCode = 400 + throw error + } + + debug('User found, password matches') + + return UserAccount.from(validUser) + }) + } + + /** + * Initializes a session (for subsequent authentication/authorization) with + * a given user's credentials. + * + * @param validUser {UserAccount} + */ + initUserSession (validUser) { + let session = this.session + + session.userId = validUser.webId + session.identified = true + session.subject = { + _id: validUser.webId + } + } + + /** + * Returns the /authorize url to redirect the user to after the login form. + * + * @return {string} + */ + authorizeUrl () { + let host = this.accountManager.host + let authUrl = host.authEndpoint + authUrl.query = this.authQueryParams + + return url.format(authUrl) + } + + /** + * Redirects the Login request to continue on the OIDC auth workflow. + */ + redirectToAuthorize () { + let authUrl = this.authorizeUrl() + + debug('Login successful, redirecting to /authorize') + + this.response.redirect(authUrl) + } +} + +/** + * Hidden form fields from the login page that must be passed through to the + * Authentication request. + * + * @type {Array} + */ +LoginByPasswordRequest.AUTH_QUERY_PARAMS = ['response_type', 'display', 'scope', + 'client_id', 'redirect_uri', 'state', 'nonce'] + +module.exports.LoginByPasswordRequest = LoginByPasswordRequest diff --git a/lib/utils.js b/lib/utils.js index f3228a525..ee56928a3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -13,12 +13,25 @@ module.exports.stringToStream = stringToStream module.exports.reqToPath = reqToPath module.exports.debrack = debrack module.exports.stripLineEndings = stripLineEndings +module.exports.fullUrlForReq = fullUrlForReq var fs = require('fs') var path = require('path') var S = require('string') var $rdf = require('rdflib') var from = require('from2') +var url = require('url') + +function fullUrlForReq (req) { + let fullUrl = url.format({ + protocol: req.protocol, + host: req.get('host'), + // pathname: req.originalUrl + pathname: req.path, + query: req.query + }) + return fullUrl +} function debrack (s) { if (s.length < 2) { diff --git a/package.json b/package.json index 3d3358732..8dc092876 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,12 @@ "cors": "^2.7.1", "debug": "^2.2.0", "express": "^4.13.3", + "express-handlebars": "^3.0.0", "express-session": "^1.11.3", "extend": "^3.0.0", "from2": "^2.1.0", "fs-extra": "^0.30.0", - "glob": "^7.0.0", + "glob": "^7.1.1", "handlebars": "^4.0.6", "inquirer": "^1.0.2", "ip-range-check": "0.0.1", @@ -53,9 +54,12 @@ "mime-types": "^2.1.11", "moment": "^2.13.0", "negotiator": "^0.6.0", + "node-fetch": "^1.6.3", "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", + "oidc-auth-manager": "^0.0.6", + "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", "request": "^2.72.0", @@ -72,14 +76,13 @@ "webid": "^0.3.7" }, "devDependencies": { - "chai": "^3.0.0", + "chai": "^3.5.0", "hippie": "^0.5.0", "mocha": "^3.2.0", "nock": "^9.0.2", "node-mocks-http": "^1.5.6", "nyc": "^10.1.2", "proxyquire": "^1.7.10", - "run-waterfall": "^1.1.3", "sinon": "^1.17.7", "sinon-chai": "^2.8.0", "standard": "^8.6.0", @@ -91,6 +94,9 @@ "standard": "standard", "mocha": "nyc mocha ./test/*.js", "test": "npm run standard && npm run mocha", + "mocha": "nyc mocha ./test/**/*.js", + "test-integration": "mocha ./test/integration/*.js", + "test-unit": "mocha ./test/unit/*.js", "test-debug": "DEBUG='solid:*' ./node_modules/mocha/bin/mocha ./test/*.js", "test-acl": "./node_modules/mocha/bin/mocha ./test/acl.js", "test-params": "./node_modules/mocha/bin/mocha ./test/params.js", diff --git a/static/oidc/css/bootstrap-3.3.6.min.css b/static/oidc/css/bootstrap-3.3.6.min.css new file mode 100644 index 000000000..08e293045 --- /dev/null +++ b/static/oidc/css/bootstrap-3.3.6.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.welcome.map */ diff --git a/static/oidc/discover-provider.html b/static/oidc/discover-provider.html new file mode 100644 index 000000000..10d55980c --- /dev/null +++ b/static/oidc/discover-provider.html @@ -0,0 +1,38 @@ + + + + + + Provider Discovery + + + + +
+
+

Provider Discovery

+
+
+
+
+
+ + + +
+ +
+
+ + + diff --git a/static/oidc/signed_out.html b/static/oidc/signed_out.html new file mode 100644 index 000000000..7e017a899 --- /dev/null +++ b/static/oidc/signed_out.html @@ -0,0 +1,37 @@ + + + + + + Signed Out + + + + +
+

You have signed out.

+
+
+
+
+ + + +
+ +
+
+ + + diff --git a/test/api-accounts.js b/test/api-accounts.js deleted file mode 100644 index 468c185cc..000000000 --- a/test/api-accounts.js +++ /dev/null @@ -1,131 +0,0 @@ -const Solid = require('../') -const parallel = require('run-parallel') -const waterfall = require('run-waterfall') -const path = require('path') -const supertest = require('supertest') -const expect = require('chai').expect -const nock = require('nock') -// In this test we always assume that we are Alice - -describe('Accounts API', () => { - let aliceServer - let bobServer - let alice - let bob - - const alicePod = Solid.createServer({ - root: path.join(__dirname, '/resources/accounts-scenario/alice'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), - auth: 'oidc', - dataBrowser: false, - fileBrowser: false, - webid: true - }) - const bobPod = Solid.createServer({ - root: path.join(__dirname, '/resources/accounts-scenario/bob'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), - auth: 'oidc', - dataBrowser: false, - fileBrowser: false, - webid: true - }) - - function getBobFoo (alice, bob, done) { - bob.get('/foo') - .expect(401) - .end((err, res) => { - if (err) return done(err) - expect(res).to.match(/META http-equiv="refresh"/) - done() - }) - } - - function postBobDiscoverSignIn (alice, bob, done) { - done() - } - - function entersPasswordAndConsent (alice, bob, done) { - done() - } - - before(function (done) { - parallel([ - (cb) => { - aliceServer = alicePod.listen(5000, cb) - alice = supertest('https://localhost:5000') - }, - (cb) => { - bobServer = bobPod.listen(5001, cb) - bob = supertest('https://localhost:5001') - } - ], done) - }) - - after(function () { - if (aliceServer) aliceServer.close() - if (bobServer) bobServer.close() - }) - - describe('endpoints', () => { - describe('/api/accounts/signin', () => { - it('should complain if a URL is missing', (done) => { - alice.post('/api/accounts/signin') - .expect(400) - .end(done) - }) - it('should complain if a URL is invalid', (done) => { - alice.post('/api/accounts/signin') - .send('webid=HELLO') - .expect(400) - .end(done) - }) - it("should return a 400 if endpoint doesn't have Link Headers", (done) => { - nock('https://amazingwebsite.tld').intercept('/', 'OPTIONS').reply(200) - alice.post('/api/accounts/signin') - .send('webid=https://amazingwebsite.tld/') - .expect(400) - .end(done) - }) - it("should return a 400 if endpoint doesn't have oidc in the headers", (done) => { - nock('https://amazingwebsite.tld') - .intercept('/', 'OPTIONS') - .reply(200, '', { - 'Link': function (req, res, body) { - return '; rel="oidc.issuer"' - } - }) - alice.post('/api/accounts/signin') - .send('webid=https://amazingwebsite.tld/') - .expect(302) - .end((err, res) => { - expect(res.header.location).to.eql('https://oidc.amazingwebsite.tld') - done(err) - }) - }) - }) - }) - - describe('Auth workflow', () => { - it.skip('step1: User tries to get /foo and gets 401 and meta redirect', (done) => { - getBobFoo(alice, bob, done) - }) - - it.skip('step2: User enters webId to signin', (done) => { - postBobDiscoverSignIn(alice, bob, done) - }) - - it.skip('step3: User enters password', (done) => { - entersPasswordAndConsent(alice, bob, done) - }) - - it.skip('entire flow', (done) => { - waterfall([ - (cb) => getBobFoo(alice, bob, cb), - (cb) => postBobDiscoverSignIn(alice, bob, cb), - (cb) => entersPasswordAndConsent(alice, bob, cb) - ], done) - }) - }) -}) diff --git a/test/create-account-request.js b/test/create-account-request.js deleted file mode 100644 index 325ec9537..000000000 --- a/test/create-account-request.js +++ /dev/null @@ -1,209 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() -const HttpMocks = require('node-mocks-http') - -const LDP = require('../lib/ldp') -const AccountManager = require('../lib/models/account-manager') -const SolidHost = require('../lib/models/solid-host') -const { CreateAccountRequest } = require('../lib/requests/create-account-request') - -var host, store, accountManager -var aliceData, userAccount -var req, session, res - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - accountManager = AccountManager.from({ - host, - store, - authMethod: 'tls', - multiUser: true - }) - - aliceData = { - username: 'alice', - spkac: '123' - } - userAccount = accountManager.userAccountFrom(aliceData) - - session = {} - req = { - body: aliceData, - session - } - res = HttpMocks.createResponse() -}) - -describe('CreateAccountRequest', () => { - describe('constructor()', () => { - it('should create an instance with the given config', () => { - let options = { accountManager, userAccount, session, response: res } - let request = new CreateAccountRequest(options) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount).to.equal(userAccount) - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - }) - }) - - describe('fromParams()', () => { - it('should create subclass depending on authMethod', () => { - let accountManager = AccountManager.from({ host, authMethod: 'tls' }) - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - expect(request).to.respondTo('generateTlsCertificate') - }) - }) - - describe('createAccount()', () => { - it('should return a 400 error if account already exists', done => { - let accountManager = AccountManager.from({ host }) - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) - - request.createAccount() - .catch(err => { - expect(err.status).to.equal(400) - done() - }) - }) - - it('should return a UserAccount instance', () => { - let multiUser = true - let accountManager = AccountManager.from({ host, store, multiUser }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - accountManager.createAccountFor = sinon.stub().returns(Promise.resolve()) - - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.sendResponse = sinon.stub() - request.generateCredentials = (userAccount) => { - return Promise.resolve(userAccount) - } - - return request.createAccount() - .then(newUser => { - expect(newUser.username).to.equal('alice') - expect(newUser.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) - - it('should call generateCredentials()', () => { - let accountManager = AccountManager.from({ host, store }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - accountManager.createAccountFor = sinon.stub().returns(Promise.resolve()) - - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.generateCredentials = (userAccount) => { - return Promise.resolve(userAccount) - } - request.sendResponse = sinon.stub() - let generateCredentials = sinon.spy(request, 'generateCredentials') - - return request.createAccount() - .then(() => { - expect(generateCredentials).to.have.been.called - }) - }) - - it('should call createAccountStorage()', () => { - let accountManager = AccountManager.from({ host, store }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - accountManager.createAccountFor = sinon.stub().returns(Promise.resolve()) - - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.generateCredentials = (userAccount) => { - return Promise.resolve(userAccount) - } - request.sendResponse = sinon.stub() - let createAccountStorage = sinon.spy(request, 'createAccountStorage') - - return request.createAccount() - .then(() => { - expect(createAccountStorage).to.have.been.called - }) - }) - - it('should call initSession()', () => { - let accountManager = AccountManager.from({ host, store }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - accountManager.createAccountFor = sinon.stub().returns(Promise.resolve()) - - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.generateCredentials = (userAccount) => { - return Promise.resolve(userAccount) - } - request.sendResponse = sinon.stub() - let initSession = sinon.spy(request, 'initSession') - - return request.createAccount() - .then(() => { - expect(initSession).to.have.been.called - }) - }) - - it('should call sendResponse()', () => { - let accountManager = AccountManager.from({ host, store }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - accountManager.createAccountFor = sinon.stub().returns(Promise.resolve()) - - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.generateCredentials = (userAccount) => { - return Promise.resolve(userAccount) - } - request.sendResponse = sinon.stub() - - return request.createAccount() - .then(() => { - expect(request.sendResponse).to.have.been.called - }) - }) - }) -}) - -describe('CreateTlsAccountRequest', () => { - let authMethod = 'tls' - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - let accountManager = AccountManager.from({ host, store, authMethod }) - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.spkac).to.equal(aliceData.spkac) - }) - }) - - describe('generateCredentials()', () => { - it('should call generateTlsCertificate()', () => { - let accountManager = AccountManager.from({ host, store, authMethod }) - let request = CreateAccountRequest.fromParams(req, res, accountManager) - - request.generateTlsCertificate = (userAccount) => { - return Promise.resolve(userAccount) - } - let generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') - - return request.generateCredentials(userAccount) - .then(() => { - expect(generateTlsCertificate).to.have.been.calledWith(userAccount) - }) - }) - }) -}) diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc.js new file mode 100644 index 000000000..7a996c458 --- /dev/null +++ b/test/integration/account-creation-oidc.js @@ -0,0 +1,196 @@ +const supertest = require('supertest') +// Helper functions for the FS +const $rdf = require('rdflib') + +const { rm, read } = require('../test-utils') +const ldnode = require('../../index') +const path = require('path') + +describe('AccountManager (OIDC account creation tests)', function () { + this.timeout(10000) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + var serverUri = 'https://localhost:3457' + var host = 'localhost:3457' + var ldpHttpsServer + var ldp = ldnode.createServer({ + root: path.join(__dirname, '../resources/accounts/'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'oidc', + webid: true, + idp: true, + strictOrigin: true, + dbPath: path.join(__dirname, '../resources/db/oidc'), + serverUri + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(3457, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + }) + + var server = supertest(serverUri) + + it('should expect a 404 on GET /accounts', function (done) { + server.get('/api/accounts') + .expect(404, done) + }) + + describe('accessing accounts', function () { + it('should be able to access public file of an account', function (done) { + var subdomain = supertest('https://tim.' + host) + subdomain.get('/hello.html') + .expect(200, done) + }) + it('should get 404 if root does not exist', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.get('/') + .set('Accept', 'text/turtle') + .set('Origin', 'http://example.com') + .expect(404) + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect('Access-Control-Allow-Credentials', 'true') + .end(function (err, res) { + done(err) + }) + }) + }) + + describe('creating an account with POST', function () { + beforeEach(function () { + rm('accounts/nicola.localhost') + }) + + after(function () { + rm('accounts/nicola.localhost') + }) + + it('should not create WebID if no username is given', (done) => { + let subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=&password=12345') + .expect(400, done) + }) + + it('should not create WebID if no password is given', (done) => { + let subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=') + .expect(400, done) + }) + + it('should not create a WebID if it already exists', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345') + .expect(201) + .end((err, res) => { + if (err) { + return done(err) + } + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345') + .expect(400) + .end((err) => { + done(err) + }) + }) + }).timeout(20000) + + it('should create the default folders', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345') + .expect(201) + .end(function (err) { + if (err) { + return done(err) + } + var domain = host.split(':')[0] + var card = read(path.join('accounts/nicola.' + domain, + 'profile/card')) + var cardAcl = read(path.join('accounts/nicola.' + domain, + 'profile/card.acl')) + var prefs = read(path.join('accounts/nicola.' + domain, + 'settings/prefs.ttl')) + var inboxAcl = read(path.join('accounts/nicola.' + domain, + 'inbox/.acl')) + var rootMeta = read(path.join('accounts/nicola.' + domain, '.meta')) + var rootMetaAcl = read(path.join('accounts/nicola.' + domain, + '.meta.acl')) + + if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && + rootMetaAcl) { + done() + } else { + done(new Error('failed to create default files')) + } + }) + }).timeout(20000) + + it('should link WebID to the root account', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345') + .expect(201) + .end(function (err) { + if (err) { + return done(err) + } + subdomain.get('/.meta') + .expect(200) + .end(function (err, data) { + if (err) { + return done(err) + } + var graph = $rdf.graph() + $rdf.parse( + data.text, + graph, + 'https://nicola.' + host + '/.meta', + 'text/turtle') + var statements = graph.statementsMatching( + undefined, + $rdf.sym('http://www.w3.org/ns/solid/terms#account'), + undefined) + if (statements.length === 1) { + done() + } else { + done(new Error('missing link to WebID of account')) + } + }) + }) + }).timeout(20000) + + it('should create a private settings container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/settings/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private prefs file in the settings container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/prefs.ttl') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private inbox container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + }) +}) diff --git a/test/account-creation.js b/test/integration/account-creation-tls.js similarity index 94% rename from test/account-creation.js rename to test/integration/account-creation-tls.js index 5e862480d..cfc3e8e5c 100644 --- a/test/account-creation.js +++ b/test/integration/account-creation-tls.js @@ -1,10 +1,10 @@ -var supertest = require('supertest') +const supertest = require('supertest') // Helper functions for the FS -var rm = require('./test-utils').rm -var $rdf = require('rdflib') -var read = require('./test-utils').read -var ldnode = require('../index') -var path = require('path') +const $rdf = require('rdflib') + +const { rm, read } = require('../test-utils') +const ldnode = require('../../index') +const path = require('path') describe('AccountManager (account creation tests)', function () { this.timeout(10000) @@ -14,9 +14,10 @@ describe('AccountManager (account creation tests)', function () { var host = 'localhost:3457' var ldpHttpsServer var ldp = ldnode.createServer({ - root: path.join(__dirname, '/resources/accounts/'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + root: path.join(__dirname, '../resources/accounts/'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'tls', webid: true, idp: true, strictOrigin: true diff --git a/test/integration/account-manager.js b/test/integration/account-manager.js new file mode 100644 index 000000000..23d8ce332 --- /dev/null +++ b/test/integration/account-manager.js @@ -0,0 +1,121 @@ +'use strict' + +const path = require('path') +const fs = require('fs-extra') +const chai = require('chai') +const expect = chai.expect +chai.should() + +const LDP = require('../../lib/ldp') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') + +const testAccountsDir = path.join(__dirname, '../resources/accounts') +const accountTemplatePath = path.join(__dirname, '../../default-account-template') + +console.log('accountTemplatePath: ', accountTemplatePath) + +var host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +afterEach(() => { + fs.removeSync(path.join(__dirname, '../resources/accounts/alice.example.com')) +}) + +describe('AccountManager', () => { + describe('accountExists()', () => { + let host = SolidHost.from({ serverUri: 'https://localhost' }) + + describe('in multi user mode', () => { + let multiUser = true + let store = new LDP({ root: testAccountsDir, idp: multiUser }) + let options = { multiUser, store, host } + let accountManager = AccountManager.from(options) + + it('resolves to true if a directory for the account exists in root', () => { + // Note: test/resources/accounts/tim.localhost/ exists in this repo + return accountManager.accountExists('tim') + .then(exists => { + expect(exists).to.be.true + }) + }) + + it('resolves to false if a directory for the account does not exist', () => { + // Note: test/resources/accounts/alice.localhost/ does NOT exist + return accountManager.accountExists('alice') + .then(exists => { + expect(exists).to.be.false + }) + }) + }) + + describe('in single user mode', () => { + let multiUser = false + + it('resolves to true if root .acl exists in root storage', () => { + let store = new LDP({ + root: path.join(testAccountsDir, 'tim.localhost'), + idp: multiUser + }) + let options = { multiUser, store, host } + let accountManager = AccountManager.from(options) + + return accountManager.accountExists() + .then(exists => { + expect(exists).to.be.true + }) + }) + + it('resolves to false if root .acl does not exist in root storage', () => { + let store = new LDP({ + root: testAccountsDir, + idp: multiUser + }) + let options = { multiUser, store, host } + let accountManager = AccountManager.from(options) + + return accountManager.accountExists() + .then(exists => { + expect(exists).to.be.false + }) + }) + }) + }) + + describe('createAccountFor()', () => { + it('should create an account directory', () => { + let multiUser = true + let store = new LDP({ root: testAccountsDir, idp: multiUser }) + let options = { host, multiUser, store, accountTemplatePath } + let accountManager = AccountManager.from(options) + + let userData = { + username: 'alice', + email: 'alice@example.com', + name: 'Alice Q.' + } + let userAccount = accountManager.userAccountFrom(userData) + + let accountDir = accountManager.accountDirFor('alice') + + return accountManager.createAccountFor(userAccount) + .then(() => { + return accountManager.accountExists('alice') + }) + .then(found => { + expect(found).to.be.true + }) + .then(() => { + let profile = fs.readFileSync(path.join(accountDir, '/profile/card'), 'utf8') + expect(profile).to.include('"Alice Q."') + + let rootAcl = fs.readFileSync(path.join(accountDir, '.acl'), 'utf8') + expect(rootAcl).to.include('') + }) + }) + }) +}) diff --git a/test/integration/account-template.js b/test/integration/account-template.js new file mode 100644 index 000000000..1cf3bfece --- /dev/null +++ b/test/integration/account-template.js @@ -0,0 +1,58 @@ +'use strict' + +const path = require('path') +const fs = require('fs-extra') +const chai = require('chai') +const expect = chai.expect +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() + +const AccountTemplate = require('../../lib/models/account-template') + +const templatePath = path.join(__dirname, '../../default-account-template') +const accountPath = path.join(__dirname, '../resources/new-account') + +describe('AccountTemplate', () => { + beforeEach(() => { + fs.removeSync(accountPath) + }) + + afterEach(() => { + fs.removeSync(accountPath) + }) + + describe('copy()', () => { + it('should copy a directory', () => { + return AccountTemplate.copyTemplateDir(templatePath, accountPath) + .then(() => { + let rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') + expect(rootAcl).to.exist + }) + }) + }) + + describe('processAccount()', () => { + it('should process all the files in an account', () => { + let substitutions = { + webId: 'https://alice.example.com/#me', + email: 'alice@example.com', + name: 'Alice Q.' + } + let template = new AccountTemplate({ substitutions }) + + return AccountTemplate.copyTemplateDir(templatePath, accountPath) + .then(() => { + return template.processAccount(accountPath) + }) + .then(() => { + let profile = fs.readFileSync(path.join(accountPath, '/profile/card'), 'utf8') + expect(profile).to.include('"Alice Q."') + + let rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') + expect(rootAcl).to.include('') + }) + }) + }) +}) diff --git a/test/acl.js b/test/integration/acl.js similarity index 96% rename from test/acl.js rename to test/integration/acl.js index 75586e4db..46d6b72b0 100644 --- a/test/acl.js +++ b/test/integration/acl.js @@ -5,12 +5,12 @@ var request = require('request') var path = require('path') // Helper functions for the FS -var rm = require('./test-utils').rm +var rm = require('../test-utils').rm // var write = require('./test-utils').write // var cp = require('./test-utils').cp // var read = require('./test-utils').read -var ldnode = require('../index') +var ldnode = require('../../index') var ns = require('solid-namespace')($rdf) describe('ACL HTTP', function () { @@ -22,9 +22,9 @@ describe('ACL HTTP', function () { var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', - root: path.join(__dirname, '/resources'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + root: path.join(__dirname, '../resources'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, strictOrigin: true }) @@ -58,12 +58,12 @@ describe('ACL HTTP', function () { var user2 = 'https://user2.databox.me/profile/card#me' var userCredentials = { user1: { - cert: fs.readFileSync(path.join(__dirname, '/keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user1-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) }, user2: { - cert: fs.readFileSync(path.join(__dirname, '/keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user2-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) } } @@ -340,7 +340,7 @@ describe('ACL HTTP', function () { }) describe('Read-only', function () { - var body = fs.readFileSync(path.join(__dirname, '/resources/acl/read-acl/.acl')) + var body = fs.readFileSync(path.join(__dirname, '../resources/acl/read-acl/.acl')) it('user1 should be able to access ACL file', function (done) { var options = createOptions('/acl/read-acl/.acl', 'user1') request.head(options, function (error, response, body) { @@ -926,18 +926,18 @@ describe('ACL HTTP', function () { // }) }) - describe.skip('Cleaup', function () { + describe.skip('Cleanup', function () { it('should remove all files and dirs created', function (done) { try { // must remove the ACLs in sync - fs.unlinkSync(path.join(__dirname, '/resources/' + testDir + '/dir1/dir2/abcd.ttl')) - fs.rmdirSync(path.join(__dirname, '/resources/' + testDir + '/dir1/dir2/')) - fs.rmdirSync(path.join(__dirname, '/resources/' + testDir + '/dir1/')) - fs.unlinkSync(path.join(__dirname, '/resources/' + abcFile)) - fs.unlinkSync(path.join(__dirname, '/resources/' + testDirAclFile)) - fs.unlinkSync(path.join(__dirname, '/resources/' + testDirMetaFile)) - fs.rmdirSync(path.join(__dirname, '/resources/' + testDir)) - fs.rmdirSync(path.join(__dirname, '/resources/acl/')) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/abcd.ttl')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/')) + fs.unlinkSync(path.join(__dirname, '../resources/' + abcFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirAclFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirMetaFile)) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir)) + fs.rmdirSync(path.join(__dirname, '../resources/acl/')) done() } catch (e) { done(e) diff --git a/test/api-messages.js b/test/integration/api-messages.js similarity index 79% rename from test/api-messages.js rename to test/integration/api-messages.js index 39cb1a586..2c7ae1fe0 100644 --- a/test/api-messages.js +++ b/test/integration/api-messages.js @@ -1,5 +1,4 @@ -const Solid = require('../') -const parallel = require('run-parallel') +const Solid = require('../../index') const path = require('path') const hippie = require('hippie') const fs = require('fs') @@ -9,18 +8,18 @@ describe.skip('Messages API', () => { let aliceServer const bobCert = { - cert: fs.readFileSync(path.join(__dirname, '/keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user2-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) } const aliceCert = { - cert: fs.readFileSync(path.join(__dirname, '/keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user1-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) } const alicePod = Solid.createServer({ - root: path.join(__dirname, '/resources/messaging-scenario'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + root: path.join(__dirname, '../resources/messaging-scenario'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), auth: 'tls', dataBrowser: false, fileBrowser: false, @@ -28,12 +27,8 @@ describe.skip('Messages API', () => { idp: true }) - before(function (done) { - parallel([ - (cb) => { - aliceServer = alicePod.listen(5000, cb) - } - ], done) + before((done) => { + aliceServer = alicePod.listen(5000, done) }) after(function () { @@ -50,7 +45,7 @@ describe.skip('Messages API', () => { }) it('should send 406 if message is missing', (done) => { hippie() - // .json() + // .json() .use(function (options, next) { options.agentOptions = bobCert options.strictSSL = false @@ -62,7 +57,7 @@ describe.skip('Messages API', () => { }) it('should send 403 user is not of this IDP', (done) => { hippie() - // .json() + // .json() .use(function (options, next) { options.agentOptions = bobCert options.strictSSL = false @@ -76,7 +71,7 @@ describe.skip('Messages API', () => { }) it('should send 406 if not destination `to` is specified', (done) => { hippie() - // .json() + // .json() .use(function (options, next) { options.agentOptions = aliceCert options.strictSSL = false @@ -90,7 +85,7 @@ describe.skip('Messages API', () => { }) it('should send 406 if not destination `to` is missing the protocol', (done) => { hippie() - // .json() + // .json() .use(function (options, next) { options.agentOptions = aliceCert options.strictSSL = false @@ -104,7 +99,7 @@ describe.skip('Messages API', () => { }) it('should send 406 if messaging protocol is not supported', (done) => { hippie() - // .json() + // .json() .use(function (options, next) { options.agentOptions = aliceCert options.strictSSL = false diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc.js new file mode 100644 index 000000000..0c046fd69 --- /dev/null +++ b/test/integration/authentication-oidc.js @@ -0,0 +1,198 @@ +const Solid = require('../../index') +const path = require('path') +const supertest = require('supertest') +const expect = require('chai').expect +const nock = require('nock') +const fs = require('fs-extra') +const { UserStore } = require('oidc-auth-manager') +const UserAccount = require('../../lib/models/user-account') + +// In this test we always assume that we are Alice + +describe('Authentication API (OIDC)', () => { + let alice, aliceServer + let bob, bobServer + + let aliceServerUri = 'https://localhost:7000' + let aliceWebId = 'https://localhost:7000/profile/card#me' + let aliceDbPath = path.join(__dirname, '../resources/db/alice') + let userStorePath = path.join(aliceDbPath, 'users') + let aliceUserStore = UserStore.from({ path: userStorePath, saltRounds: 1 }) + aliceUserStore.initCollections() + + let bobServerUri = 'https://localhost:7001' + let bobDbPath = path.join(__dirname, '../resources/db/bob') + + const serverConfig = { + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'oidc', + dataBrowser: false, + fileBrowser: false, + webid: true, + idp: false + } + + const alicePod = Solid.createServer( + Object.assign({ + root: path.join(__dirname, '../resources/accounts-scenario/alice'), + serverUri: aliceServerUri, + dbPath: aliceDbPath + }, serverConfig) + ) + const bobPod = Solid.createServer( + Object.assign({ + root: path.join(__dirname, '../resources/accounts-scenario/bob'), + serverUri: bobServerUri, + dbPath: bobDbPath + }, serverConfig) + ) + + function startServer (pod, port) { + return new Promise((resolve) => { + pod.listen(port, () => { resolve() }) + }) + } + + before(() => { + return Promise.all([ + startServer(alicePod, 7000), + startServer(bobPod, 7001) + ]).then(() => { + alice = supertest(aliceServerUri) + bob = supertest(bobServerUri) + }) + }) + + after(() => { + if (aliceServer) aliceServer.close() + if (bobServer) bobServer.close() + fs.removeSync(aliceDbPath) + fs.removeSync(bobDbPath) + }) + + describe('Provider Discovery (POST /api/auth/discover)', () => { + it('form should load on a get', done => { + alice.get('/api/auth/discover') + .expect(200) + .expect((res) => { res.text.match(/Provider Discovery/) }) + .end(done) + }) + + it('should complain if WebID URI is missing', (done) => { + alice.post('/api/auth/discover') + .expect(400, done) + }) + + it('should prepend https:// to webid, if necessary', (done) => { + alice.post('/api/auth/discover') + .type('form') + .send({ webid: 'localhost:7000' }) + .expect(302, done) + }) + + it("should return a 400 if endpoint doesn't have Link Headers", (done) => { + // Fake provider, replies with 200 and no Link headers + nock('https://amazingwebsite.tld').intercept('/', 'OPTIONS').reply(204) + + alice.post('/api/auth/discover') + .send('webid=https://amazingwebsite.tld/') + .expect(400) + .end(done) + }) + + it('should redirect user to discovered provider if valid uri', (done) => { + bob.post('/api/auth/discover') + .send('webid=' + aliceServerUri) + .expect(302) + .end((err, res) => { + let loginUri = res.header.location + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + done(err) + }) + }) + }) + + describe('Login by Username and Password (POST /login)', done => { + // Logging in as alice, to alice's pod + let aliceAccount = UserAccount.from({ webId: aliceWebId }) + let alicePassword = '12345' + + beforeEach(() => { + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .catch(console.error.bind(console)) + }) + + afterEach(() => { + fs.removeSync(aliceDbPath) + }) + + it('should login and be redirected to /authorize', (done) => { + alice.post('/login') + .type('form') + .send({ username: 'alice' }) + .send({ password: alicePassword }) + .expect(302) + .expect('set-cookie', /connect.sid/) + .end((err, res) => { + let loginUri = res.header.location + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + done(err) + }) + }) + + it('should throw a 400 if no username is provided', (done) => { + alice.post('/login') + .type('form') + .send({ password: alicePassword }) + .expect(400, done) + }) + + it('should throw a 400 if no password is provided', (done) => { + alice.post('/login') + .type('form') + .send({ username: 'alice' }) + .expect(400, done) + }) + + it('should throw a 400 if user is found but no password match', (done) => { + alice.post('/login') + .type('form') + .send({ username: 'alice' }) + .send({ password: 'wrongpassword' }) + .expect(400, done) + }) + }) + + describe('Login workflow', () => { + // Step 1: Alice tries to access bob.com/foo, and + // gets redirected to bob.com's Provider Discovery endpoint + it('401 Unauthorized -> redirect to provider discovery', (done) => { + bob.get('/foo') + .expect(401) + .end((err, res) => { + if (err) return done(err) + let redirectString = 'http-equiv="refresh" ' + + `content="0; url=${bobServerUri}/api/auth/discover` + expect(res.text).to.match(new RegExp(redirectString)) + done() + }) + }) + + // Step 2: Alice enters her WebID URI to the Provider Discovery endpoint + it('Enter webId -> redirect to provider login', (done) => { + bob.post('/api/auth/discover') + .send('webid=' + aliceServerUri) + .expect(302) + .end((err, res) => { + let loginUri = res.header.location + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + done(err) + }) + }) + + it('At /login, enter WebID & password -> redirect back to /foo') + }) +}) diff --git a/test/capability-discovery.js b/test/integration/capability-discovery.js similarity index 59% rename from test/capability-discovery.js rename to test/integration/capability-discovery.js index 63f5ce4ec..c934c5350 100644 --- a/test/capability-discovery.js +++ b/test/integration/capability-discovery.js @@ -1,5 +1,4 @@ -const Solid = require('../') -const parallel = require('run-parallel') +const Solid = require('../../index') const path = require('path') const supertest = require('supertest') const expect = require('chai').expect @@ -8,49 +7,47 @@ const expect = require('chai').expect describe('API', () => { let aliceServer let alice + let serverUri = 'https://localhost:5000' const alicePod = Solid.createServer({ - root: path.join(__dirname, '/resources/accounts-scenario/alice'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + root: path.join(__dirname, '../resources/accounts-scenario/alice'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), auth: 'oidc', + serverUri, dataBrowser: false, fileBrowser: false, webid: true }) - before(function (done) { - parallel([ - (cb) => { - aliceServer = alicePod.listen(5000, cb) - alice = supertest('https://localhost:5000') - } - ], done) + before((done) => { + aliceServer = alicePod.listen(5000, done) + alice = supertest(serverUri) }) - after(function () { + after(() => { if (aliceServer) aliceServer.close() }) - describe('Capability Discovery', function () { - describe('GET Service Capability document', function () { - it('should exist', function (done) { + describe('Capability Discovery', () => { + describe('GET Service Capability document', () => { + it('should exist', (done) => { alice.get('/.well-known/solid') .expect(200, done) }) - it('should be a json file by default', function (done) { + it('should be a json file by default', (done) => { alice.get('/.well-known/solid') .expect('content-type', /application\/json/) .expect(200, done) }) - it('includes a root element', function (done) { + it('includes a root element', (done) => { alice.get('/.well-known/solid') .end(function (err, req) { expect(req.body.root).to.exist return done(err) }) }) - it('includes an apps config section', function (done) { + it('includes an apps config section', (done) => { const config = { apps: { 'signin': '/signin/', @@ -67,18 +64,25 @@ describe('API', () => { }) }) - describe('OPTIONS API', function () { - it('should set the service Link header', function (done) { + describe('OPTIONS API', () => { + it('should return the service Link header', (done) => { alice.options('/') .expect('Link', /<.*\.well-known\/solid>; rel="service"/) .expect(204, done) }) - it('should still have previous link headers', function (done) { + + it('should still have previous link headers', (done) => { alice.options('/') .expect('Link', /; rel="type"/) .expect('Link', /; rel="type"/) .expect(204, done) }) + + it('should return the oidc.provider Link header', (done) => { + alice.options('/') + .expect('Link', /; rel="oidc.provider"/) + .expect(204, done) + }) }) }) }) diff --git a/test/errors.js b/test/integration/errors.js similarity index 84% rename from test/errors.js rename to test/integration/errors.js index d73059ca2..8b97522b5 100644 --- a/test/errors.js +++ b/test/integration/errors.js @@ -5,21 +5,21 @@ var path = require('path') // var rm = require('./test-utils').rm // var write = require('./test-utils').write // var cp = require('./test-utils').cp -var read = require('./test-utils').read +var read = require('./../test-utils').read -var ldnode = require('../index') +var ldnode = require('../../index') describe('Error pages', function () { // LDP with error pages var errorLdp = ldnode({ - root: path.join(__dirname, '/resources'), - errorPages: path.join(__dirname, '/resources/errorPages') + root: path.join(__dirname, '../resources'), + errorPages: path.join(__dirname, '../resources/errorPages') }) var errorServer = supertest(errorLdp) // LDP with no error pages var noErrorLdp = ldnode({ - root: path.join(__dirname, '/resources'), + root: path.join(__dirname, '../resources'), noErrorPages: true }) var noErrorServer = supertest(noErrorLdp) diff --git a/test/formats.js b/test/integration/formats.js similarity index 98% rename from test/formats.js rename to test/integration/formats.js index e4b216d2a..57443f67d 100644 --- a/test/formats.js +++ b/test/integration/formats.js @@ -1,10 +1,10 @@ var supertest = require('supertest') -var ldnode = require('../index') +var ldnode = require('../../index') var path = require('path') describe('formats', function () { var ldp = ldnode.createServer({ - root: path.join(__dirname, '/resources') + root: path.join(__dirname, '../resources') }) var server = supertest(ldp) diff --git a/test/http-copy.js b/test/integration/http-copy.js similarity index 78% rename from test/http-copy.js rename to test/integration/http-copy.js index 3f9368bc7..9a372700c 100644 --- a/test/http-copy.js +++ b/test/integration/http-copy.js @@ -3,9 +3,9 @@ var fs = require('fs') var request = require('request') var path = require('path') // Helper functions for the FS -var rm = require('./test-utils').rm +var rm = require('./../test-utils').rm -var solidServer = require('../index') +var solidServer = require('../../index') describe('HTTP COPY API', function () { this.timeout(10000) @@ -15,9 +15,9 @@ describe('HTTP COPY API', function () { var ldpHttpsServer var ldp = solidServer.createServer({ - root: path.join(__dirname, 'resources/accounts/localhost/'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem') + root: path.join(__dirname, '../resources/accounts/localhost/'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem') }) before(function (done) { @@ -34,12 +34,12 @@ describe('HTTP COPY API', function () { var userCredentials = { user1: { - cert: fs.readFileSync(path.join(__dirname, '/keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user1-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) }, user2: { - cert: fs.readFileSync(path.join(__dirname, '/keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '/keys/user2-key.pem')) + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) } } @@ -65,7 +65,7 @@ describe('HTTP COPY API', function () { assert.equal(error, null) assert.equal(response.statusCode, 201) assert.equal(response.headers[ 'location' ], copyTo) - let destinationPath = path.join(__dirname, 'resources/accounts/localhost', copyTo) + let destinationPath = path.join(__dirname, '../resources/accounts/localhost', copyTo) assert.ok(fs.existsSync(destinationPath), 'Resource created via COPY should exist') done() diff --git a/test/http.js b/test/integration/http.js similarity index 95% rename from test/http.js rename to test/integration/http.js index 3840d1ab7..e2d44b7c7 100644 --- a/test/http.js +++ b/test/integration/http.js @@ -1,8 +1,8 @@ var supertest = require('supertest') var fs = require('fs') var li = require('li') -var ldnode = require('../index') -var rm = require('./test-utils').rm +var ldnode = require('../../index') +var rm = require('./../test-utils').rm var path = require('path') const rdf = require('rdflib') @@ -11,7 +11,8 @@ var suffixMeta = '.meta' var ldpServer = ldnode.createServer({ live: true, dataBrowserPath: 'default', - root: path.join(__dirname, '/resources') + root: path.join(__dirname, '../resources'), + auth: 'oidc' }) var server = supertest(ldpServer) var assert = require('chai').assert @@ -104,7 +105,7 @@ describe('HTTP APIs', function () { .expect('Access-Control-Allow-Origin', 'http://example.com') .expect('Access-Control-Allow-Credentials', 'true') .expect('Access-Control-Allow-Methods', 'OPTIONS,HEAD,GET,PATCH,POST,PUT,DELETE') - .expect('Access-Control-Expose-Headers', 'User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length') + .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length') .expect(204, done) }) @@ -194,7 +195,7 @@ describe('HTTP APIs', function () { } var size = fs.statSync(path.join(__dirname, - '/resources/sampleContainer/solid.png')).size + '../resources/sampleContainer/solid.png')).size if (res.body.length !== size) { return done(new Error('files are not of the same size')) } @@ -398,7 +399,7 @@ describe('HTTP APIs', function () { describe('PUT API', function () { var putRequestBody = fs.readFileSync(path.join(__dirname, - '/resources/sampleContainer/put1.ttl'), { + '../resources/sampleContainer/put1.ttl'), { 'encoding': 'utf8' }) it('should create new resource', function (done) { @@ -483,11 +484,11 @@ describe('HTTP APIs', function () { }) var postRequest1Body = fs.readFileSync(path.join(__dirname, - '/resources/sampleContainer/put1.ttl'), { + '../resources/sampleContainer/put1.ttl'), { 'encoding': 'utf8' }) var postRequest2Body = fs.readFileSync(path.join(__dirname, - '/resources/sampleContainer/post2.ttl'), { + '../resources/sampleContainer/post2.ttl'), { 'encoding': 'utf8' }) it('should create new resource', function (done) { @@ -557,7 +558,7 @@ describe('HTTP APIs', function () { .expect(201) .end(function (err) { if (err) return done(err) - var stats = fs.statSync(path.join(__dirname, '/resources/post-tests/loans/')) + var stats = fs.statSync(path.join(__dirname, '../resources/post-tests/loans/')) if (!stats.isDirectory()) { return done(new Error('Cannot read container just created')) } @@ -583,7 +584,7 @@ describe('HTTP APIs', function () { try { assert.equal(res.headers.location, expectedDirName, 'Uri container names should be encoded') - let createdDir = fs.statSync(path.join(__dirname, 'resources', expectedDirName)) + let createdDir = fs.statSync(path.join(__dirname, '../resources', expectedDirName)) assert(createdDir.isDirectory(), 'Container should have been created') } catch (err) { return done(err) @@ -636,19 +637,19 @@ describe('HTTP APIs', function () { it('should create as many files as the ones passed in multipart', function (done) { server.post('/sampleContainer/') - .attach('timbl', path.join(__dirname, '/resources/timbl.jpg')) - .attach('nicola', path.join(__dirname, '/resources/nicola.jpg')) + .attach('timbl', path.join(__dirname, '../resources/timbl.jpg')) + .attach('nicola', path.join(__dirname, '../resources/nicola.jpg')) .expect(200) .end(function (err) { if (err) return done(err) var sizeNicola = fs.statSync(path.join(__dirname, - '/resources/nicola.jpg')).size - var sizeTim = fs.statSync(path.join(__dirname, '/resources/timbl.jpg')).size + '../resources/nicola.jpg')).size + var sizeTim = fs.statSync(path.join(__dirname, '../resources/timbl.jpg')).size var sizeNicolaLocal = fs.statSync(path.join(__dirname, - '/resources/sampleContainer/nicola.jpg')).size + '../resources/sampleContainer/nicola.jpg')).size var sizeTimLocal = fs.statSync(path.join(__dirname, - '/resources/sampleContainer/timbl.jpg')).size + '../resources/sampleContainer/timbl.jpg')).size if (sizeNicola === sizeNicolaLocal && sizeTim === sizeTimLocal) { return done() diff --git a/test/ldp.js b/test/integration/ldp.js similarity index 87% rename from test/ldp.js rename to test/integration/ldp.js index 34dd6c661..508e17bcc 100644 --- a/test/ldp.js +++ b/test/integration/ldp.js @@ -1,20 +1,20 @@ var assert = require('chai').assert var $rdf = require('rdflib') var ns = require('solid-namespace')($rdf) -var LDP = require('../lib/ldp') +var LDP = require('../../lib/ldp') var path = require('path') -var stringToStream = require('../lib/utils').stringToStream +var stringToStream = require('../../lib/utils').stringToStream // Helper functions for the FS -var rm = require('./test-utils').rm -var write = require('./test-utils').write +var rm = require('./../test-utils').rm +var write = require('./../test-utils').write // var cp = require('./test-utils').cp -var read = require('./test-utils').read +var read = require('./../test-utils').read var fs = require('fs') describe('LDP', function () { var ldp = new LDP({ - root: __dirname + root: path.join(__dirname, '..') }) describe('readFile', function () { @@ -28,7 +28,7 @@ describe('LDP', function () { it('return file if file exists', function (done) { // file can be empty as well write('hello world', 'fileExists.txt') - ldp.readFile(path.join(__dirname, '/resources/fileExists.txt'), function (err, file) { + ldp.readFile(path.join(__dirname, '../resources/fileExists.txt'), function (err, file) { rm('fileExists.txt') assert.notOk(err) assert.equal(file, 'hello world') @@ -48,7 +48,7 @@ describe('LDP', function () { it('should return content if metaFile exists', function (done) { // file can be empty as well write('This function just reads this, does not parse it', '.meta') - ldp.readContainerMeta(path.join(__dirname, '/resources/'), function (err, metaFile) { + ldp.readContainerMeta(path.join(__dirname, '../resources/'), function (err, metaFile) { rm('.meta') assert.notOk(err) assert.equal(metaFile, 'This function just reads this, does not parse it') @@ -59,7 +59,7 @@ describe('LDP', function () { it('should work also if trailing `/` is not passed', function (done) { // file can be empty as well write('This function just reads this, does not parse it', '.meta') - ldp.readContainerMeta(path.join(__dirname, '/resources'), function (err, metaFile) { + ldp.readContainerMeta(path.join(__dirname, '../resources'), function (err, metaFile) { rm('.meta') assert.notOk(err) assert.equal(metaFile, 'This function just reads this, does not parse it') @@ -162,7 +162,7 @@ describe('LDP', function () { ' dcterms:title "This is a magic type" ;' + ' o:limit 500000.00 .', 'sampleContainer/magicType.ttl') - ldp.listContainer(path.join(__dirname, '/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { + ldp.listContainer(path.join(__dirname, '../resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { if (err) done(err) var graph = $rdf.graph() $rdf.parse( @@ -205,7 +205,7 @@ describe('LDP', function () { ' dcterms:title "This is a container" ;' + ' o:limit 500000.00 .', 'sampleContainer/basicContainerFile.ttl') - ldp.listContainer(path.join(__dirname, '/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { + ldp.listContainer(path.join(__dirname, '../resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { if (err) done(err) var graph = $rdf.graph() $rdf.parse( @@ -245,9 +245,9 @@ describe('LDP', function () { }) it('should ldp:contains the same amount of files in dir', function (done) { - ldp.listContainer(path.join(__dirname, '/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { + ldp.listContainer(path.join(__dirname, '../resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'text/turtle', function (err, data) { if (err) done(err) - fs.readdir(path.join(__dirname, '/resources/sampleContainer/'), function (err, files) { + fs.readdir(path.join(__dirname, '../resources/sampleContainer/'), function (err, files) { var graph = $rdf.graph() $rdf.parse( data, diff --git a/test/params.js b/test/integration/params.js similarity index 89% rename from test/params.js rename to test/integration/params.js index 481743ece..9b4942487 100644 --- a/test/params.js +++ b/test/integration/params.js @@ -2,12 +2,12 @@ var assert = require('chai').assert var supertest = require('supertest') var path = require('path') // Helper functions for the FS -var rm = require('./test-utils').rm -var write = require('./test-utils').write +var rm = require('../test-utils').rm +var write = require('../test-utils').write // var cp = require('./test-utils').cp -var read = require('./test-utils').read +var read = require('../test-utils').read -var ldnode = require('../index') +var ldnode = require('../../index') describe('LDNODE params', function () { describe('suffixMeta', function () { @@ -83,7 +83,7 @@ describe('LDNODE params', function () { describe('ui-path', function () { var ldp = ldnode({ root: './test/resources/', - apiApps: path.join(__dirname, 'resources/sampleContainer') + apiApps: path.join(__dirname, '../resources/sampleContainer') }) var server = supertest(ldp) @@ -98,9 +98,9 @@ describe('LDNODE params', function () { var ldpHttpsServer var ldp = ldnode.createServer({ forceUser: 'https://fakeaccount.com/profile#me', - root: path.join(__dirname, '/resources/acl/fake-account'), - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + root: path.join(__dirname, '../resources/acl/fake-account'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, host: 'localhost:3457' }) diff --git a/test/patch-2.js b/test/integration/patch-2.js similarity index 97% rename from test/patch-2.js rename to test/integration/patch-2.js index 23476936b..af716130f 100644 --- a/test/patch-2.js +++ b/test/integration/patch-2.js @@ -1,18 +1,18 @@ -var ldnode = require('../') +var ldnode = require('../../index') var supertest = require('supertest') var assert = require('chai').assert var path = require('path') // Helper functions for the FS -var rm = require('./test-utils').rm -var write = require('./test-utils').write +var rm = require('../test-utils').rm +var write = require('../test-utils').write // var cp = require('./test-utils').cp -var read = require('./test-utils').read +var read = require('../test-utils').read describe('PATCH', function () { // Starting LDP var ldp = ldnode({ - root: path.join(__dirname, '/resources/sampleContainer'), + root: path.join(__dirname, '../resources/sampleContainer'), mount: '/test' }) var server = supertest(ldp) diff --git a/test/patch.js b/test/integration/patch.js similarity index 96% rename from test/patch.js rename to test/integration/patch.js index 036544283..68fc9ad1d 100644 --- a/test/patch.js +++ b/test/integration/patch.js @@ -1,18 +1,18 @@ -var ldnode = require('../') +var ldnode = require('../../index') var supertest = require('supertest') var assert = require('chai').assert var path = require('path') // Helper functions for the FS -var rm = require('./test-utils').rm -var write = require('./test-utils').write +var rm = require('../test-utils').rm +var write = require('../test-utils').write // var cp = require('./test-utils').cp -var read = require('./test-utils').read +var read = require('../test-utils').read describe('PATCH', function () { // Starting LDP var ldp = ldnode({ - root: path.join(__dirname, '/resources/sampleContainer'), + root: path.join(__dirname, '../resources/sampleContainer'), mount: '/test' }) var server = supertest(ldp) diff --git a/test/proxy.js b/test/integration/proxy.js similarity index 97% rename from test/proxy.js rename to test/integration/proxy.js index 92289e55d..c7eb2c341 100644 --- a/test/proxy.js +++ b/test/integration/proxy.js @@ -4,11 +4,11 @@ var path = require('path') var nock = require('nock') var async = require('async') -var ldnode = require('../index') +var ldnode = require('../../index') describe('proxy', () => { var ldp = ldnode({ - root: path.join(__dirname, '/resources'), + root: path.join(__dirname, '../resources'), proxy: '/proxy' }) var server = supertest(ldp) diff --git a/test/account-manager.js b/test/unit/account-manager.js similarity index 81% rename from test/account-manager.js rename to test/unit/account-manager.js index 5facde2e6..e7e031698 100644 --- a/test/account-manager.js +++ b/test/unit/account-manager.js @@ -1,7 +1,7 @@ 'use strict' -const path = require('path') const fs = require('fs-extra') +const path = require('path') const chai = require('chai') const expect = chai.expect const sinon = require('sinon') @@ -10,13 +10,13 @@ chai.use(sinonChai) chai.should() const rdf = require('rdflib') -const LDP = require('../lib/ldp') -const SolidHost = require('../lib/models/solid-host') -const AccountManager = require('../lib/models/account-manager') -const UserAccount = require('../lib/models/user-account') -const WebIdTlsCertificate = require('../lib/models/webid-tls-certificate') +const LDP = require('../../lib/ldp') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') +const UserAccount = require('../../lib/models/user-account') +const WebIdTlsCertificate = require('../../lib/models/webid-tls-certificate') -const testAccountsDir = path.join(__dirname, 'resources', 'accounts') +const testAccountsDir = path.join(__dirname, '../resources/accounts') var host @@ -118,65 +118,6 @@ describe('AccountManager', () => { }) }) - describe('accountExists()', () => { - let host = SolidHost.from({ serverUri: 'https://localhost' }) - - describe('in multi user mode', () => { - let multiUser = true - let store = new LDP({ root: testAccountsDir, idp: multiUser }) - let options = { multiUser, store, host } - let accountManager = AccountManager.from(options) - - it('resolves to true if a directory for the account exists in root', () => { - // Note: test/resources/accounts/tim.localhost/ exists in this repo - return accountManager.accountExists('tim') - .then(exists => { - expect(exists).to.be.true - }) - }) - - it('resolves to false if a directory for the account does not exist', () => { - // Note: test/resources/accounts/alice.localhost/ does NOT exist - return accountManager.accountExists('alice') - .then(exists => { - expect(exists).to.be.false - }) - }) - }) - - describe('in single user mode', () => { - let multiUser = false - - it('resolves to true if root .acl exists in root storage', () => { - let store = new LDP({ - root: path.join(testAccountsDir, 'tim.localhost'), - idp: multiUser - }) - let options = { multiUser, store, host } - let accountManager = AccountManager.from(options) - - return accountManager.accountExists() - .then(exists => { - expect(exists).to.be.true - }) - }) - - it('resolves to false if root .acl does not exist in root storage', () => { - let store = new LDP({ - root: testAccountsDir, - idp: multiUser - }) - let options = { multiUser, store, host } - let accountManager = AccountManager.from(options) - - return accountManager.accountExists() - .then(exists => { - expect(exists).to.be.false - }) - }) - }) - }) - describe('userAccountFrom()', () => { describe('in multi user mode', () => { let multiUser = true @@ -333,7 +274,7 @@ describe('AccountManager', () => { it('should create an account directory', () => { let multiUser = true - let accountTemplatePath = path.join(__dirname, '../default-account-template') + let accountTemplatePath = path.join(__dirname, '../../default-account-template') let store = new LDP({ root: testAccountsDir, idp: multiUser }) let options = { host, multiUser, store, accountTemplatePath } let accountManager = AccountManager.from(options) diff --git a/test/account-template.js b/test/unit/account-template.js similarity index 56% rename from test/account-template.js rename to test/unit/account-template.js index cb99729d4..eb1d653b0 100644 --- a/test/account-template.js +++ b/test/unit/account-template.js @@ -1,38 +1,15 @@ 'use strict' -const path = require('path') -const fs = require('fs-extra') const chai = require('chai') const expect = chai.expect const sinonChai = require('sinon-chai') chai.use(sinonChai) chai.should() -const AccountTemplate = require('../lib/models/account-template') -const UserAccount = require('../lib/models/user-account') - -const templatePath = path.join(__dirname, '../default-account-template') -const accountPath = path.join(__dirname, 'resources', 'new-account') +const AccountTemplate = require('../../lib/models/account-template') +const UserAccount = require('../../lib/models/user-account') describe('AccountTemplate', () => { - beforeEach(() => { - fs.removeSync(accountPath) - }) - - afterEach(() => { - fs.removeSync(accountPath) - }) - - describe('copy()', () => { - it('should copy a directory', () => { - return AccountTemplate.copyTemplateDir(templatePath, accountPath) - .then(() => { - let rootAcl = fs.readFileSync(path.join(accountPath, '.acl')) - expect(rootAcl).to.exist - }) - }) - }) - describe('isTemplate()', () => { let template = new AccountTemplate() @@ -79,28 +56,4 @@ describe('AccountTemplate', () => { expect(substitutions.webId).to.equal('https://alice.example.com/profile/card#me') }) }) - - describe('processAccount()', () => { - it('should process all the files in an account', () => { - let substitutions = { - webId: 'https://alice.example.com/#me', - email: 'alice@example.com', - name: 'Alice Q.' - } - let template = new AccountTemplate({ substitutions }) - - return AccountTemplate.copyTemplateDir(templatePath, accountPath) - .then(() => { - return template.processAccount(accountPath) - }) - .then(() => { - let profile = fs.readFileSync(path.join(accountPath, '/profile/card'), 'utf8') - expect(profile).to.include('"Alice Q."') - - let rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') - expect(rootAcl).to.include('') - }) - }) - }) }) diff --git a/test/acl-checker.js b/test/unit/acl-checker.js similarity index 89% rename from test/acl-checker.js rename to test/unit/acl-checker.js index fd80d20b2..3d1daaffb 100644 --- a/test/acl-checker.js +++ b/test/unit/acl-checker.js @@ -1,7 +1,7 @@ 'use strict' const proxyquire = require('proxyquire') const assert = require('chai').assert -const debug = require('../lib/debug').ACL +const debug = require('../../lib/debug').ACL class PermissionSetAlwaysGrant { checkAccess () { @@ -21,7 +21,7 @@ class PermissionSetAlwaysError { describe('ACLChecker unit test', () => { it('should callback with null on grant success', done => { - let ACLChecker = proxyquire('../lib/acl-checker', { + let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetAlwaysGrant } }) let graph = {} @@ -35,7 +35,7 @@ describe('ACLChecker unit test', () => { }) }) it('should callback with error on grant failure', done => { - let ACLChecker = proxyquire('../lib/acl-checker', { + let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetNeverGrant } }) let graph = {} @@ -49,7 +49,7 @@ describe('ACLChecker unit test', () => { }) }) it('should callback with error on grant error', done => { - let ACLChecker = proxyquire('../lib/acl-checker', { + let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetAlwaysError } }) let graph = {} diff --git a/test/add-cert-request.js b/test/unit/add-cert-request.js similarity index 90% rename from test/add-cert-request.js rename to test/unit/add-cert-request.js index d13c2e829..dc854f61f 100644 --- a/test/add-cert-request.js +++ b/test/unit/add-cert-request.js @@ -12,13 +12,13 @@ chai.use(sinonChai) chai.should() const HttpMocks = require('node-mocks-http') -const SolidHost = require('../lib/models/solid-host') -const AccountManager = require('../lib/models/account-manager') -const AddCertificateRequest = require('../lib/requests/add-cert-request') -const WebIdTlsCertificate = require('../lib/models/webid-tls-certificate') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') +const AddCertificateRequest = require('../../lib/requests/add-cert-request') +const WebIdTlsCertificate = require('../../lib/models/webid-tls-certificate') const exampleSpkac = fs.readFileSync( - path.join(__dirname, './resources/example_spkac.cnf'), 'utf8' + path.join(__dirname, '../resources/example_spkac.cnf'), 'utf8' ) var host diff --git a/test/unit/auth-oidc-handler.js b/test/unit/auth-oidc-handler.js new file mode 100644 index 000000000..9d8137158 --- /dev/null +++ b/test/unit/auth-oidc-handler.js @@ -0,0 +1,42 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect + +const { + authCodeFlowCallback, + getIssuerId +} = require('../../lib/api/authn/webid-oidc') + +describe('/handlers/auth-webid-oidc', () => { + describe('authCodeFlowCallback()', () => { + it('throws a 400 error if no issuer_id present', done => { + let oidc = {} + let req = { params: {} } + authCodeFlowCallback(oidc, req) + .catch(err => { + expect(err.status).to.equal(400) + done() + }) + }) + }) + + describe('getIssuerId()', () => { + it('should return falsy when no req.params present', () => { + expect(getIssuerId()).to.not.exist + }) + + it('should return falsy when req.params.issuer_id is absent', () => { + expect(getIssuerId()).to.not.exist + }) + + it('should uri-decode issuer_id', () => { + let req = { + params: { + issuer_id: 'https%3A%2F%2Flocalhost' + } + } + expect(getIssuerId(req)).to.equal('https://localhost') + }) + }) +}) diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js new file mode 100644 index 000000000..5cf73e5c4 --- /dev/null +++ b/test/unit/create-account-request.js @@ -0,0 +1,223 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() +const HttpMocks = require('node-mocks-http') + +const LDP = require('../../lib/ldp') +const AccountManager = require('../../lib/models/account-manager') +const SolidHost = require('../../lib/models/solid-host') +const defaults = require('../../config/defaults') +const { CreateAccountRequest } = require('../../lib/requests/create-account-request') + +describe('CreateAccountRequest', () => { + let host, store, accountManager + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + accountManager = AccountManager.from({ host, store }) + + session = {} + res = HttpMocks.createResponse() + }) + + describe('constructor()', () => { + it('should create an instance with the given config', () => { + let aliceData = { username: 'alice' } + let userAccount = accountManager.userAccountFrom(aliceData) + + let options = { accountManager, userAccount, session, response: res } + let request = new CreateAccountRequest(options) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount).to.equal(userAccount) + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + }) + }) + + describe('fromParams()', () => { + it('should create subclass depending on authMethod', () => { + let request, aliceData, req + + aliceData = { username: 'alice' } + req = { app: { locals: {} }, body: aliceData, session } + req.app.locals.authMethod = 'tls' + + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.respondTo('generateTlsCertificate') + + aliceData = { username: 'alice', password: '12345' } + req = { app: { locals: { oidc: {} } }, body: aliceData, session } + req.app.locals.authMethod = 'oidc' + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.not.respondTo('generateTlsCertificate') + }) + }) + + describe('createAccount()', () => { + it('should return a 400 error if account already exists', done => { + let locals = { authMethod: defaults.AUTH_METHOD } + let aliceData = { username: 'alice' } + let req = { app: { locals }, body: aliceData } + let accountManager = AccountManager.from({ host }) + + let request = CreateAccountRequest.fromParams(req, res, accountManager) + + accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) + + request.createAccount() + .catch(err => { + expect(err.status).to.equal(400) + done() + }) + }) + }) +}) + +describe('CreateOidcAccountRequest', () => { + let authMethod = 'oidc' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + let aliceData = { username: 'alice', password: '123' } + + let userStore = {} + let req = { + app: { + locals: { authMethod, oidc: { users: userStore } } + }, + body: aliceData, + session + } + + let accountManager = AccountManager.from({ host, store }) + let request = CreateAccountRequest.fromParams(req, res, accountManager) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.password).to.equal(aliceData.password) + expect(request.userStore).to.equal(userStore) + }) + + it('should throw an error if no password was given', () => { + let aliceData = { username: 'alice', password: null } + let req = { + app: { locals: { authMethod, oidc: {} } }, + body: aliceData, + session + } + let accountManager = AccountManager.from({ host, store }) + + expect(() => { CreateAccountRequest.fromParams(req, res, accountManager) }) + .to.throw(/Password required/) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should create a new user in the user store', () => { + let password = '12345' + let aliceData = { username: 'alice', password } + let userStore = { + createUser: (userAccount, password) => { return Promise.resolve() } + } + let createUserSpy = sinon.spy(userStore, 'createUser') + let req = { + app: { locals: { authMethod, oidc: { users: userStore } } }, + body: aliceData, + session + } + let accountManager = AccountManager.from({ host, store }) + + let request = CreateAccountRequest.fromParams(req, res, accountManager) + let userAccount = request.userAccount + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(createUserSpy).to.have.been.calledWith(userAccount, password) + }) + }) + }) + + describe('sendResponse()', () => { + it('should respond with a 201 Created', () => { + let aliceData = { username: 'alice', password: '12345' } + let req = { + app: { locals: { authMethod, oidc: {} } }, + body: aliceData, + session + } + let accountManager = AccountManager.from({ host, store }) + + let request = CreateAccountRequest.fromParams(req, res, accountManager) + let userAccount = request.userAccount + + request.sendResponse(userAccount) + expect(request.response.statusCode).to.equal(201) + }) + }) +}) + +describe('CreateTlsAccountRequest', () => { + let authMethod = 'tls' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + let aliceData = { username: 'alice' } + let req = { app: { locals: { authMethod } }, body: aliceData, session } + + let accountManager = AccountManager.from({ host, store }) + let request = CreateAccountRequest.fromParams(req, res, accountManager) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.spkac).to.equal(aliceData.spkac) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should call generateTlsCertificate()', () => { + let aliceData = { username: 'alice' } + let req = { app: { locals: { authMethod } }, body: aliceData, session } + + let accountManager = AccountManager.from({ host, store }) + let request = CreateAccountRequest.fromParams(req, res, accountManager) + let userAccount = accountManager.userAccountFrom(aliceData) + + let generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(generateTlsCertificate).to.have.been.calledWith(userAccount) + }) + }) + }) +}) diff --git a/test/unit/discover-provider-request.js b/test/unit/discover-provider-request.js new file mode 100644 index 000000000..adb729970 --- /dev/null +++ b/test/unit/discover-provider-request.js @@ -0,0 +1,86 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const HttpMocks = require('node-mocks-http') + +const DiscoverProviderRequest = require('../../lib/requests/discover-provider-request') + +describe('DiscoverProviderRequest', () => { + describe('normalizeWebId()', () => { + it('should prepend https:// if one is missing', () => { + let result = DiscoverProviderRequest.normalizeUri('localhost:8443') + expect(result).to.equal('https://localhost:8443') + }) + + it('should return null if given a null uri', () => { + let result = DiscoverProviderRequest.normalizeUri(null) + expect(result).to.be.null + }) + + it('should return a valid uri unchanged', () => { + let result = DiscoverProviderRequest.normalizeUri('https://alice.example.com') + expect(result).to.equal('https://alice.example.com') + }) + }) + + describe('fromParams()', () => { + let res = HttpMocks.createResponse() + + it('should initialize a DiscoverProviderRequest instance', () => { + let aliceWebId = 'https://alice.example.com' + let oidcManager = {} + let session = {} + let req = { + session, + body: { webid: aliceWebId }, + app: { locals: { oidc: oidcManager } } + } + + let request = DiscoverProviderRequest.fromParams(req, res) + expect(request.webId).to.equal(aliceWebId) + expect(request.response).to.equal(res) + expect(request.oidcManager).to.equal(oidcManager) + expect(request.session).to.equal(session) + }) + + it('should throw a 500 error if no oidcManager was initialized', (done) => { + let aliceWebId = 'https://alice.example.com' + let req = { + body: { webid: aliceWebId } + // no app.locals.oidc + } + + try { + DiscoverProviderRequest.fromParams(req, res) + } catch (error) { + expect(error.statusCode).to.equal(500) + done() + } + }) + + it('should throw a 400 error if no webid is submitted', (done) => { + let req = {} + + try { + DiscoverProviderRequest.fromParams(req, res) + } catch (error) { + expect(error.statusCode).to.equal(400) + done() + } + }) + + it('should attempt to normalize an invalid webid uri', () => { + let oidcManager = {} + let session = {} + let req = { + session, + body: { webid: 'alice.example.com' }, + app: { locals: { oidc: oidcManager } } + } + + let request = DiscoverProviderRequest.fromParams(req, res) + expect(request.webId).to.equal('https://alice.example.com') + }) + }) +}) diff --git a/test/email-service.js b/test/unit/email-service.js similarity index 95% rename from test/email-service.js rename to test/unit/email-service.js index dc88f54be..2bd68d69c 100644 --- a/test/email-service.js +++ b/test/unit/email-service.js @@ -1,4 +1,4 @@ -const EmailService = require('../lib/models/email-service') +const EmailService = require('../../lib/models/email-service') const path = require('path') const sinon = require('sinon') const chai = require('chai') @@ -7,12 +7,12 @@ const sinonChai = require('sinon-chai') chai.use(sinonChai) chai.should() -const templatePath = path.join(__dirname, '../default-email-templates') +const templatePath = path.join(__dirname, '../../default-email-templates') describe('Email Service', function () { describe('EmailService constructor', () => { it('should set up a nodemailer instance', () => { - let templatePath = '../config/email-templates' + let templatePath = '../../config/email-templates' let config = { host: 'smtp.gmail.com', auth: { @@ -91,7 +91,7 @@ describe('Email Service', function () { describe('templatePathFor()', () => { it('should compose filename based on base path and template name', () => { let config = { host: 'databox.me', auth: {} } - let templatePath = '../config/email-templates' + let templatePath = '../../config/email-templates' let emailService = new EmailService(templatePath, config) let templateFile = emailService.templatePathFor('welcome') diff --git a/test/unit/login-by-password-request.js b/test/unit/login-by-password-request.js new file mode 100644 index 000000000..42fd08b28 --- /dev/null +++ b/test/unit/login-by-password-request.js @@ -0,0 +1,411 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() +const HttpMocks = require('node-mocks-http') +const url = require('url') + +// const defaults = require('../../config/defaults') +const { + // LoginRequest, + LoginByPasswordRequest +} = require('../../lib/requests/login-request') + +const UserAccount = require('../../lib/models/user-account') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const authMethod = 'oidc' +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host, authMethod }) + +describe('LoginByPasswordRequest', () => { + describe('handle()', () => { + let res, req + + beforeEach(() => { + req = { + app: { locals: { oidc: { users: mockUserStore }, accountManager } }, + body: { username: 'alice', password: '12345' } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + let fromParams = sinon.spy(LoginByPasswordRequest, 'fromParams') + let loginStub = sinon.stub(LoginByPasswordRequest, 'login') + .returns(Promise.resolve()) + + return LoginByPasswordRequest.handle(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.reset() + loginStub.restore() + }) + .catch(error => { + fromParams.reset() + loginStub.restore() + throw error + }) + }) + + it('should invoke login()', () => { + let login = sinon.spy(LoginByPasswordRequest, 'login') + + return LoginByPasswordRequest.handle(req, res) + .then(() => { + expect(login).to.have.been.called + login.reset() + }) + }) + }) + + describe('fromParams()', () => { + let session = {} + let userStore = {} + let req = { + session, + app: { locals: { oidc: { users: userStore }, accountManager } }, + body: { username: 'alice', password: '12345' } + } + let res = HttpMocks.createResponse() + + it('should return a LoginByPasswordRequest instance', () => { + let request = LoginByPasswordRequest.fromParams(req, res) + + expect(request.username).to.equal('alice') + expect(request.password).to.equal('12345') + expect(request.response).to.equal(res) + expect(request.session).to.equal(session) + expect(request.userStore).to.equal(userStore) + expect(request.accountManager).to.equal(accountManager) + }) + + it('should initialize the query params', () => { + let extractQueryParams = sinon.spy(LoginByPasswordRequest, 'extractQueryParams') + LoginByPasswordRequest.fromParams(req, res) + + expect(extractQueryParams).to.be.calledWith(req.body) + }) + }) + + describe('login()', () => { + let userStore = mockUserStore + let response + + beforeEach(() => { + response = HttpMocks.createResponse() + }) + + it('should invoke validate()', () => { + let request = new LoginByPasswordRequest({ userStore, accountManager, response }) + + let validate = sinon.stub(request, 'validate') + + return LoginByPasswordRequest.login(request) + .then(() => { + expect(validate).to.have.been.called + }) + }) + + it('should call findValidUser()', () => { + let request = new LoginByPasswordRequest({ userStore, accountManager, response }) + request.validate = sinon.stub() + + let findValidUser = sinon.spy(request, 'findValidUser') + + return LoginByPasswordRequest.login(request) + .then(() => { + expect(findValidUser).to.have.been.called + }) + }) + + it('should call initUserSession() for a valid user', () => { + let validUser = {} + let request = new LoginByPasswordRequest({ userStore, accountManager, response }) + + request.validate = sinon.stub() + request.findValidUser = sinon.stub().returns(Promise.resolve(validUser)) + + let initUserSession = sinon.spy(request, 'initUserSession') + + return LoginByPasswordRequest.login(request) + .then(() => { + expect(initUserSession).to.have.been.calledWith(validUser) + }) + }) + + it('should call redirectToAuthorize()', () => { + let validUser = {} + let request = new LoginByPasswordRequest({ userStore, accountManager, response }) + + request.validate = sinon.stub() + request.findValidUser = sinon.stub().returns(Promise.resolve(validUser)) + + let redirectToAuthorize = sinon.spy(request, 'redirectToAuthorize') + + return LoginByPasswordRequest.login(request) + .then(() => { + expect(redirectToAuthorize).to.have.been.called + }) + }) + }) + + describe('validate()', () => { + it('should throw a 400 error if no username was provided', done => { + let options = { username: null, password: '12345' } + let request = new LoginByPasswordRequest(options) + + try { + request.validate() + } catch (error) { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('Username required') + done() + } + }) + + it('should throw a 400 error if no password was provided', done => { + let options = { username: 'alice', password: null } + let request = new LoginByPasswordRequest(options) + + try { + request.validate() + } catch (error) { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('Password required') + done() + } + }) + }) + + describe('findValidUser()', () => { + it('should throw a 400 if no valid user is found in the user store', done => { + let request = new LoginByPasswordRequest({ accountManager }) + + request.userStore = { + findUser: () => { return Promise.resolve(false) } + } + + request.findValidUser() + .catch(error => { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('No user found for that username') + done() + }) + }) + + it('should throw a 400 if user is found but password does not match', done => { + let request = new LoginByPasswordRequest({ accountManager }) + + request.userStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: () => { return Promise.resolve(false) } + } + + request.findValidUser() + .catch(error => { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('User found but no password found') + done() + }) + }) + + it('should return a valid user if one is found and password matches', () => { + let webId = 'https://alice.example.com/#me' + let validUser = { username: 'alice', webId } + let request = new LoginByPasswordRequest({ accountManager }) + + request.userStore = { + findUser: () => { return Promise.resolve(validUser) }, + matchPassword: (user, password) => { return Promise.resolve(user) } + } + + return request.findValidUser() + .then(foundUser => { + expect(foundUser.webId).to.equal(webId) + }) + }) + + describe('in Multi User mode', () => { + let multiUser = true + let serverUri = 'https://example.com' + let host = SolidHost.from({ serverUri }) + let accountManager = AccountManager.from({ multiUser, host }) + let mockUserStore + + beforeEach(() => { + mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } + } + }) + + it('should load user from store if provided with username', () => { + let options = { username: 'alice', userStore: mockUserStore, accountManager } + let request = new LoginByPasswordRequest(options) + + let storeFindUser = sinon.spy(request.userStore, 'findUser') + let userStoreKey = 'alice.example.com/profile/card#me' + + return request.findValidUser() + .then(() => { + expect(storeFindUser).to.be.calledWith(userStoreKey) + }) + }) + + it('should load user from store if provided with WebID', () => { + let webId = 'https://alice.example.com/profile/card#me' + let options = { username: webId, userStore: mockUserStore, accountManager } + let request = new LoginByPasswordRequest(options) + + let storeFindUser = sinon.spy(request.userStore, 'findUser') + let userStoreKey = 'alice.example.com/profile/card#me' + + return request.findValidUser() + .then(() => { + expect(storeFindUser).to.be.calledWith(userStoreKey) + }) + }) + }) + + describe('in Single User mode', () => { + let multiUser = false + let serverUri = 'https://localhost:8443' + let host = SolidHost.from({ serverUri }) + let accountManager = AccountManager.from({ multiUser, host }) + let mockUserStore + + beforeEach(() => { + mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } + } + }) + + it('should load user from store if provided with username', () => { + let options = { username: 'alice', userStore: mockUserStore, accountManager } + let request = new LoginByPasswordRequest(options) + + let storeFindUser = sinon.spy(request.userStore, 'findUser') + let userStoreKey = 'localhost:8443/profile/card#me' + + return request.findValidUser() + .then(() => { + expect(storeFindUser).to.be.calledWith(userStoreKey) + }) + }) + + it('should load user from store if provided with WebID', () => { + let webId = 'https://localhost:8443/profile/card#me' + let options = { username: webId, userStore: mockUserStore, accountManager } + let request = new LoginByPasswordRequest(options) + + let storeFindUser = sinon.spy(request.userStore, 'findUser') + let userStoreKey = 'localhost:8443/profile/card#me' + + return request.findValidUser() + .then(() => { + expect(storeFindUser).to.be.calledWith(userStoreKey) + }) + }) + }) + }) + + describe('initUserSession()', () => { + it('should initialize the request session', () => { + let webId = 'https://alice.example.com/#me' + let alice = UserAccount.from({ username: 'alice', webId }) + let session = {} + + let request = new LoginByPasswordRequest({ session }) + + request.initUserSession(alice) + + expect(request.session.userId).to.equal(webId) + expect(request.session.identified).to.be.true + let subject = request.session.subject + expect(subject['_id']).to.equal(webId) + }) + }) + + function testAuthQueryParams () { + let body = {} + body['response_type'] = 'code' + body['scope'] = 'openid' + body['client_id'] = 'client1' + body['redirect_uri'] = 'https://redirect.example.com/' + body['state'] = '1234' + body['nonce'] = '5678' + body['display'] = 'page' + + return body + } + + describe('extractQueryParams()', () => { + let body = testAuthQueryParams() + body['other_key'] = 'whatever' + + it('should initialize the auth url query object from params', () => { + let extracted = LoginByPasswordRequest.extractQueryParams(body) + + for (let param of LoginByPasswordRequest.AUTH_QUERY_PARAMS) { + expect(extracted[param]).to.equal(body[param]) + } + + // make sure *only* the listed params were copied + expect(extracted['other_key']).to.not.exist + }) + }) + + describe('authorizeUrl()', () => { + it('should return an /authorize url', () => { + let request = new LoginByPasswordRequest({ accountManager }) + + let authUrl = request.authorizeUrl() + + expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true + }) + + it('should pass through relevant auth query params from request body', () => { + let body = testAuthQueryParams() + + let request = new LoginByPasswordRequest({ accountManager }) + request.authQueryParams = LoginByPasswordRequest.extractQueryParams(body) + + let authUrl = request.authorizeUrl() + + let parseQueryString = true + let parsedUrl = url.parse(authUrl, parseQueryString) + + for (let param in body) { + expect(body[param]).to.equal(parsedUrl.query[param]) + } + }) + }) + + describe('redirectToAuthorize()', () => { + it('should redirect to the /authorize url', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost/authorize?client_id=123' + + let request = new LoginByPasswordRequest({ accountManager, response: res }) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectToAuthorize() + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(authUrl) + }) + }) +}) diff --git a/test/unit/oidc-manager.js b/test/unit/oidc-manager.js new file mode 100644 index 000000000..206b72fe4 --- /dev/null +++ b/test/unit/oidc-manager.js @@ -0,0 +1,33 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const path = require('path') + +const OidcManager = require('../../lib/models/oidc-manager') +const SolidHost = require('../../lib/models/solid-host') + +describe('OidcManager', () => { + describe('fromServerConfig()', () => { + it('should result in an initialized oidc object', () => { + let serverUri = 'https://localhost:8443' + let host = SolidHost.from({ serverUri }) + + let dbPath = path.join(__dirname, '../resources/db') + let saltRounds = 5 + let argv = { + host, + dbPath, + saltRounds + } + + let oidc = OidcManager.fromServerConfig(argv) + + expect(oidc.rs.defaults.query).to.be.true + expect(oidc.clients.store.backend.path.endsWith('db/rp/clients')) + expect(oidc.provider.issuer).to.equal(serverUri) + expect(oidc.users.backend.path.endsWith('db/users')) + expect(oidc.users.saltRounds).to.equal(saltRounds) + }) + }) +}) diff --git a/test/solid-host.js b/test/unit/solid-host.js similarity index 78% rename from test/solid-host.js rename to test/unit/solid-host.js index 510bc43ee..e1c15a2dc 100644 --- a/test/solid-host.js +++ b/test/unit/solid-host.js @@ -2,8 +2,8 @@ const expect = require('chai').expect -const SolidHost = require('../lib/models/solid-host') -const defaults = require('../config/defaults') +const SolidHost = require('../../lib/models/solid-host') +const defaults = require('../../config/defaults') describe('SolidHost', () => { describe('from()', () => { @@ -59,4 +59,17 @@ describe('SolidHost', () => { expect(host.cookieDomain).to.equal('.example.com') }) }) + + describe('authEndpoint getter', () => { + it('should return an /authorize url object', () => { + let host = SolidHost.from({ + serverUri: 'https://localhost:8443' + }) + + let authUrl = host.authEndpoint + + expect(authUrl.host).to.equal('localhost:8443') + expect(authUrl.path).to.equal('/authorize') + }) + }) }) diff --git a/test/user-account.js b/test/unit/user-account.js similarity index 55% rename from test/user-account.js rename to test/unit/user-account.js index b84002e8f..420a346a7 100644 --- a/test/user-account.js +++ b/test/unit/user-account.js @@ -2,7 +2,7 @@ const chai = require('chai') const expect = chai.expect -const UserAccount = require('../lib/models/user-account') +const UserAccount = require('../../lib/models/user-account') describe('UserAccount', () => { describe('from()', () => { @@ -21,4 +21,19 @@ describe('UserAccount', () => { expect(account.email).to.equal(options.email) }) }) + + describe('id getter', () => { + it('should return null if webId is null', () => { + let account = new UserAccount() + + expect(account.id).to.be.null + }) + + it('should return the WebID uri minus the protocol and slashes', () => { + let webId = 'https://alice.example.com/profile/card#me' + let account = new UserAccount({ webId }) + + expect(account.id).to.equal('alice.example.com/profile/card#me') + }) + }) }) diff --git a/test/user-accounts-api.js b/test/unit/user-accounts-api.js similarity index 85% rename from test/user-accounts-api.js rename to test/unit/user-accounts-api.js index fe8822304..e894e8b73 100644 --- a/test/user-accounts-api.js +++ b/test/unit/user-accounts-api.js @@ -9,13 +9,13 @@ chai.use(sinonChai) chai.should() const HttpMocks = require('node-mocks-http') -const LDP = require('../lib/ldp') -const SolidHost = require('../lib/models/solid-host') -const AccountManager = require('../lib/models/account-manager') -const { CreateAccountRequest } = require('../lib/requests/create-account-request') -const testAccountsDir = path.join(__dirname, 'resources', 'accounts') +const LDP = require('../../lib/ldp') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') +const { CreateAccountRequest } = require('../../lib/requests/create-account-request') +const testAccountsDir = path.join(__dirname, '..', 'resources', 'accounts') -const api = require('../lib/api/accounts/user-accounts') +const api = require('../../lib/api/accounts/user-accounts') var host @@ -33,7 +33,9 @@ describe('api/accounts/user-accounts', () => { let accountManager = AccountManager.from(options) let createAccountSpy = sinon.spy(CreateAccountRequest.prototype, 'createAccount') + let authMethod = 'tls' let req = { + app: { locals: { authMethod } }, body: { username: 'alice', spkac: '123' }, session: {} } diff --git a/test/utils.js b/test/unit/utils.js similarity index 97% rename from test/utils.js rename to test/unit/utils.js index af2d3c245..a17c39e59 100644 --- a/test/utils.js +++ b/test/unit/utils.js @@ -1,6 +1,6 @@ var assert = require('chai').assert -var utils = require('../lib/utils') +var utils = require('../../lib/utils') describe('Utility functions', function () { describe('pathBasename', function () { diff --git a/views/auth/consent.hbs b/views/auth/consent.hbs new file mode 100644 index 000000000..11d425fd2 --- /dev/null +++ b/views/auth/consent.hbs @@ -0,0 +1,33 @@ + + + + + + {{title}} + + + + + +
+

Authorize app to use your Web ID?

+
+
+
+ + + + + + + + + + +
+
+ + From 7a0376f2565f24aea8e33b5749aba7aec74aba91 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Wed, 8 Mar 2017 17:03:13 -0500 Subject: [PATCH 002/178] Extract /logout handler to LogoutRequest --- lib/api/authn/webid-oidc.js | 13 ++++-- lib/models/oidc-manager.js | 14 ++----- lib/requests/logout-request.js | 57 ++++++++++++++++++++++++++ static/oidc/goodbye.html | 21 ++++++++++ static/oidc/signed_out.html | 37 ----------------- test/unit/login-by-password-request.js | 2 - test/unit/logout-request.js | 39 ++++++++++++++++++ 7 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 lib/requests/logout-request.js create mode 100644 static/oidc/goodbye.html delete mode 100644 static/oidc/signed_out.html create mode 100644 test/unit/logout-request.js diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 23f5f023b..434d9fffd 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -10,6 +10,8 @@ const error = require('../../http-error') const bodyParser = require('body-parser').urlencoded({ extended: false }) const DiscoverProviderRequest = require('../../requests/discover-provider-request') +const LogoutRequest = require('../../requests/logout-request') + const { LoginByPasswordRequest } = require('../../requests/login-request') /** @@ -41,7 +43,11 @@ function middleware (oidc) { router.post(['/login', '/signin'], bodyParser, login) - router.post('/logout', logout) + router.get('/logout', logout) + + router.get('/goodbye', (req, res) => { + res.sendFile('goodbye.html', { root: './static/oidc/' }) + }) // The relying party callback is called at the end of the OIDC signin process router.get('/api/oidc/rp/:issuer_id', (req, res, next) => { @@ -84,9 +90,8 @@ function login (req, res, next) { } function logout (req, res, next) { - req.session.userId = '' - req.session.identified = false - res.status(200).send() + return LogoutRequest.handle(req, res) + .catch(next) } function authCodeFlowCallback (oidc, req) { diff --git a/lib/models/oidc-manager.js b/lib/models/oidc-manager.js index 343c5adc9..fbc3a296f 100644 --- a/lib/models/oidc-manager.js +++ b/lib/models/oidc-manager.js @@ -4,6 +4,7 @@ const url = require('url') const debug = require('./../debug').oidc const OidcManager = require('oidc-auth-manager') +const LogoutRequest = require('../requests/logout-request') /** * Returns an instance of the OIDC Authentication Manager, initialized from @@ -29,7 +30,7 @@ function fromServerConfig (argv) { } let authCallbackUri = url.resolve(providerUri, '/api/oidc/rp') - let postLogoutUri = url.resolve(providerUri, '/signed_out.html') + let postLogoutUri = url.resolve(providerUri, '/goodbye') let options = { providerUri, @@ -101,15 +102,8 @@ function obtainConsent (authRequest) { } function logout (logoutRequest) { - let req = logoutRequest.req - req.session.accessToken = '' - req.session.refreshToken = '' - // req.session.issuer = '' - req.session.userId = '' - req.session.identified = false - // Inject post_logout_redirect_uri here? (If Accept: text/html) - debug('LOGOUT behavior') - return logoutRequest + return LogoutRequest.handle(logoutRequest.req, logoutRequest.res) + .then(() => logoutRequest) } module.exports = { diff --git a/lib/requests/logout-request.js b/lib/requests/logout-request.js new file mode 100644 index 000000000..3895beb60 --- /dev/null +++ b/lib/requests/logout-request.js @@ -0,0 +1,57 @@ +'use strict' + +const debug = require('./../debug').authentication + +class LogoutRequest { + /** + * @constructor + * @param options + * @param options.request {IncomingRequest} req + * @param options.response {ServerResponse} res + */ + constructor (options) { + this.request = options.request + this.response = options.response + } + + static handle (req, res) { + return Promise.resolve() + .then(() => { + let request = LogoutRequest.fromParams(req, res) + + return LogoutRequest.logout(request) + }) + } + + static fromParams (req, res) { + let options = { + request: req, + response: res + } + + return new LogoutRequest(options) + } + + static logout (request) { + debug(`Logging out user ${request.request.session.userId}`) + + request.clearUserSession() + request.redirectToGoodbye() + } + + clearUserSession () { + let session = this.request.session + + session.accessToken = '' + session.refreshToken = '' + session.userId = '' + session.identified = false + session.subject = '' + } + + redirectToGoodbye () { + this.response.redirect('/goodbye') + } +} + +module.exports = LogoutRequest diff --git a/static/oidc/goodbye.html b/static/oidc/goodbye.html new file mode 100644 index 000000000..0cf1d356f --- /dev/null +++ b/static/oidc/goodbye.html @@ -0,0 +1,21 @@ + + + + + + Logged Out + + + + +
+

You have logged out.

+
+
+
+ +
+
+ + diff --git a/static/oidc/signed_out.html b/static/oidc/signed_out.html deleted file mode 100644 index 7e017a899..000000000 --- a/static/oidc/signed_out.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - Signed Out - - - - -
-

You have signed out.

-
-
-
-
- - - -
- -
-
- - - diff --git a/test/unit/login-by-password-request.js b/test/unit/login-by-password-request.js index 42fd08b28..b918991c5 100644 --- a/test/unit/login-by-password-request.js +++ b/test/unit/login-by-password-request.js @@ -9,9 +9,7 @@ chai.should() const HttpMocks = require('node-mocks-http') const url = require('url') -// const defaults = require('../../config/defaults') const { - // LoginRequest, LoginByPasswordRequest } = require('../../lib/requests/login-request') diff --git a/test/unit/logout-request.js b/test/unit/logout-request.js new file mode 100644 index 000000000..a5fdb32a3 --- /dev/null +++ b/test/unit/logout-request.js @@ -0,0 +1,39 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const HttpMocks = require('node-mocks-http') + +const LogoutRequest = require('../../lib/requests/logout-request') + +describe('LogoutRequest', () => { + it('should clear user session properties', () => { + let req = { + session: { + userId: 'https://alice.example.com/#me', + identified: true, + accessToken: {}, + refreshToken: {}, + subject: {} + } + } + let res = HttpMocks.createResponse() + + return LogoutRequest.handle(req, res) + .then(() => { + let session = req.session + expect(session.userId).to.be.empty + }) + }) + + it('should redirect to /goodbye', () => { + let req = { session: {} } + let res = HttpMocks.createResponse() + + return LogoutRequest.handle(req, res) + .then(() => { + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal('/goodbye') + }) + }) +}) From 3fb80477871d12aaaca2f88347915ebf8ec039de Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Wed, 8 Mar 2017 17:19:24 -0500 Subject: [PATCH 003/178] Move Provider initialization logic --- lib/capability-discovery.js | 4 ++-- lib/create-app.js | 13 +++++++++++++ lib/models/oidc-manager.js | 14 ++------------ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/capability-discovery.js b/lib/capability-discovery.js index 10e3f3901..058988512 100644 --- a/lib/capability-discovery.js +++ b/lib/capability-discovery.js @@ -14,8 +14,8 @@ const serviceConfigDefaults = { // Create new user (see IdentityProvider.post() in identity-provider.js) 'new': '/api/accounts/new', 'recover': '/api/accounts/recover', - 'signin': '/api/accounts/signin', - 'signout': '/api/accounts/signout', + 'signin': '/login', + 'signout': '/logout', 'validateToken': '/api/accounts/validateToken' } } diff --git a/lib/create-app.js b/lib/create-app.js index 89d994a9c..9f8ff9795 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -190,6 +190,19 @@ function initAuthentication (argv, app) { // and OIDC-specific ones app.use('/', API.oidc.middleware(oidc)) + oidc.initialize() + .then(() => { + oidc.saveProviderConfig() + return oidc.clients.clientForIssuer(argv.serverUri) + }) + .then(localClient => { + console.log('Local RP client initialized') + oidc.localRp = localClient + }) + .catch(error => { + console.error(error) + }) + // Enforce authentication with WebID-OIDC on all LDP routes app.use('/', oidc.rs.authenticate()) break diff --git a/lib/models/oidc-manager.js b/lib/models/oidc-manager.js index fbc3a296f..23b63c8b4 100644 --- a/lib/models/oidc-manager.js +++ b/lib/models/oidc-manager.js @@ -40,18 +40,8 @@ function fromServerConfig (argv) { saltRounds: argv.saltRounds, host: { authenticate, obtainConsent, logout } } - let oidc = OidcManager.from(options) - oidc.initialize() - .then(() => { - oidc.saveProviderConfig() - return oidc.clients.clientForIssuer(providerUri) - }) - .then(localClient => { - console.log('Local RP client initialized') - oidc.localRp = localClient - }) - - return oidc + + return OidcManager.from(options) } // This gets called from OIDC Provider's /authorize endpoint From 935be63b261712499fe4eda8263b74da77d54b57 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 9 Mar 2017 15:47:31 -0500 Subject: [PATCH 004/178] Handle user manually going to /login without app --- lib/requests/login-request.js | 23 +++++++--- test/unit/login-by-password-request.js | 60 +++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index e5f687ee3..c215b97b9 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -129,7 +129,7 @@ class LoginByPasswordRequest { }) .then(validUser => { request.initUserSession(validUser) - request.redirectToAuthorize() + request.redirectPostLogin(validUser) }) } @@ -145,9 +145,12 @@ class LoginByPasswordRequest { let extracted = {} let paramKeys = LoginByPasswordRequest.AUTH_QUERY_PARAMS + let value for (let p of paramKeys) { - extracted[p] = body[p] + value = body[p] + value = value === 'undefined' ? undefined : value + extracted[p] = value } return extracted @@ -257,12 +260,20 @@ class LoginByPasswordRequest { /** * Redirects the Login request to continue on the OIDC auth workflow. */ - redirectToAuthorize () { - let authUrl = this.authorizeUrl() + redirectPostLogin (validUser) { + let uri - debug('Login successful, redirecting to /authorize') + if (this.authQueryParams['redirect_uri']) { + // Login request is part of an app's auth flow + uri = this.authorizeUrl() + } else { + // Login request is a user going to /login in browser + uri = this.accountManager.accountUriFor(validUser.username) + } + + debug('Login successful, redirecting to ', uri) - this.response.redirect(authUrl) + this.response.redirect(uri) } } diff --git a/test/unit/login-by-password-request.js b/test/unit/login-by-password-request.js index b918991c5..2f9cf1b95 100644 --- a/test/unit/login-by-password-request.js +++ b/test/unit/login-by-password-request.js @@ -142,18 +142,18 @@ describe('LoginByPasswordRequest', () => { }) }) - it('should call redirectToAuthorize()', () => { + it('should call redirectPostLogin()', () => { let validUser = {} let request = new LoginByPasswordRequest({ userStore, accountManager, response }) request.validate = sinon.stub() request.findValidUser = sinon.stub().returns(Promise.resolve(validUser)) - let redirectToAuthorize = sinon.spy(request, 'redirectToAuthorize') + let redirectPostLogin = sinon.spy(request, 'redirectPostLogin') return LoginByPasswordRequest.login(request) .then(() => { - expect(redirectToAuthorize).to.have.been.called + expect(redirectPostLogin).to.have.been.calledWith(validUser) }) }) }) @@ -391,19 +391,65 @@ describe('LoginByPasswordRequest', () => { }) }) - describe('redirectToAuthorize()', () => { - it('should redirect to the /authorize url', () => { + describe('redirectPostLogin()', () => { + it('should redirect to the /authorize url if redirect_uri is present', () => { let res = HttpMocks.createResponse() let authUrl = 'https://localhost/authorize?client_id=123' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) - let request = new LoginByPasswordRequest({ accountManager, response: res }) + let authQueryParams = { + redirect_uri: 'https://app.example.com/callback' + } + + let options = { accountManager, authQueryParams, response: res } + let request = new LoginByPasswordRequest(options) request.authorizeUrl = sinon.stub().returns(authUrl) - request.redirectToAuthorize() + request.redirectPostLogin(validUser) expect(res.statusCode).to.equal(302) expect(res._getRedirectUrl()).to.equal(authUrl) }) }) + + it('should redirect to account uri if no redirect_uri present', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost/authorize?client_id=123' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) + + let authQueryParams = {} + + let options = { accountManager, authQueryParams, response: res } + let request = new LoginByPasswordRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + let expectedUri = accountManager.accountUriFor('alice') + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + + it('should redirect to account uri if redirect_uri is string "undefined', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost/authorize?client_id=123' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) + + let body = { redirect_uri: 'undefined' } + + let options = { accountManager, response: res } + let request = new LoginByPasswordRequest(options) + request.authQueryParams = LoginByPasswordRequest.extractQueryParams(body) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + let expectedUri = accountManager.accountUriFor('alice') + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) }) From e7b4d0f6bc9a59bb55c5c6c3d27339ae1ede390b Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 10 Mar 2017 16:09:47 -0500 Subject: [PATCH 005/178] Extract auth api logic to oidc-auth-manager --- default-email-templates/welcome.js | 2 +- lib/api/authn/webid-oidc.js | 9 ----- lib/create-app.js | 11 ------ lib/models/oidc-manager.js | 62 ++---------------------------- lib/requests/logout-request.js | 57 --------------------------- package.json | 5 +-- test/unit/logout-request.js | 39 ------------------- 7 files changed, 6 insertions(+), 179 deletions(-) delete mode 100644 lib/requests/logout-request.js delete mode 100644 test/unit/logout-request.js diff --git a/default-email-templates/welcome.js b/default-email-templates/welcome.js index 21ca3ba61..bce554462 100644 --- a/default-email-templates/welcome.js +++ b/default-email-templates/welcome.js @@ -14,7 +14,7 @@ */ function render (data) { return { - subject: `Welcome to Solid`, + subject: 'Welcome to Solid', /** * Text version of the Welcome email diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 434d9fffd..2e2a1ae0e 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -10,7 +10,6 @@ const error = require('../../http-error') const bodyParser = require('body-parser').urlencoded({ extended: false }) const DiscoverProviderRequest = require('../../requests/discover-provider-request') -const LogoutRequest = require('../../requests/logout-request') const { LoginByPasswordRequest } = require('../../requests/login-request') @@ -43,8 +42,6 @@ function middleware (oidc) { router.post(['/login', '/signin'], bodyParser, login) - router.get('/logout', logout) - router.get('/goodbye', (req, res) => { res.sendFile('goodbye.html', { root: './static/oidc/' }) }) @@ -89,11 +86,6 @@ function login (req, res, next) { }) } -function logout (req, res, next) { - return LogoutRequest.handle(req, res) - .catch(next) -} - function authCodeFlowCallback (oidc, req) { debug.oidc('in authCodeFlowCallback()') @@ -161,7 +153,6 @@ module.exports = { middleware, discoverProvider, login, - logout, extractWebId, authCodeFlowCallback, getIssuerId, diff --git a/lib/create-app.js b/lib/create-app.js index 9f8ff9795..a3d3eadd6 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -191,17 +191,6 @@ function initAuthentication (argv, app) { app.use('/', API.oidc.middleware(oidc)) oidc.initialize() - .then(() => { - oidc.saveProviderConfig() - return oidc.clients.clientForIssuer(argv.serverUri) - }) - .then(localClient => { - console.log('Local RP client initialized') - oidc.localRp = localClient - }) - .catch(error => { - console.error(error) - }) // Enforce authentication with WebID-OIDC on all LDP routes app.use('/', oidc.rs.authenticate()) diff --git a/lib/models/oidc-manager.js b/lib/models/oidc-manager.js index 23b63c8b4..00444bb7b 100644 --- a/lib/models/oidc-manager.js +++ b/lib/models/oidc-manager.js @@ -1,10 +1,9 @@ 'use strict' const url = require('url') -const debug = require('./../debug').oidc +const debug = require('./../debug').authentication const OidcManager = require('oidc-auth-manager') -const LogoutRequest = require('../requests/logout-request') /** * Returns an instance of the OIDC Authentication Manager, initialized from @@ -38,67 +37,12 @@ function fromServerConfig (argv) { authCallbackUri, postLogoutUri, saltRounds: argv.saltRounds, - host: { authenticate, obtainConsent, logout } + host: { debug } } return OidcManager.from(options) } -// This gets called from OIDC Provider's /authorize endpoint -function authenticate (authRequest) { - let session = authRequest.req.session - debug('AUTHENTICATE injected method') - - if (session.identified && session.userId) { - debug('User webId found in session: ', session.userId) - - authRequest.subject = { - _id: session.userId // put webId into the IDToken's subject claim - } - } else { - // User not authenticated, send them to login - debug('User not authenticated, sending to /login') - - let loginUrl = url.parse('/login') - loginUrl.query = authRequest.req.query - loginUrl = url.format(loginUrl) - authRequest.subject = null - authRequest.res.redirect(loginUrl) - } - return authRequest -} - -function obtainConsent (authRequest) { - if (authRequest.subject) { - let { req, res } = authRequest - - if (req.body.consent) { - authRequest.consent = true - authRequest.scope = authRequest.params.scope - debug('OBTAINED CONSENT') - } else { - let params = req.query['client_id'] ? req.query : req.body - - // let clientId = params['client_id'] - // let locals = req.app.locals - // let clientStore = locals.oidc.clients - - res.render('auth/consent', params) - authRequest.headersSent = true - } - } - - return authRequest -} - -function logout (logoutRequest) { - return LogoutRequest.handle(logoutRequest.req, logoutRequest.res) - .then(() => logoutRequest) -} - module.exports = { - fromServerConfig, - authenticate, - obtainConsent, - logout + fromServerConfig } diff --git a/lib/requests/logout-request.js b/lib/requests/logout-request.js deleted file mode 100644 index 3895beb60..000000000 --- a/lib/requests/logout-request.js +++ /dev/null @@ -1,57 +0,0 @@ -'use strict' - -const debug = require('./../debug').authentication - -class LogoutRequest { - /** - * @constructor - * @param options - * @param options.request {IncomingRequest} req - * @param options.response {ServerResponse} res - */ - constructor (options) { - this.request = options.request - this.response = options.response - } - - static handle (req, res) { - return Promise.resolve() - .then(() => { - let request = LogoutRequest.fromParams(req, res) - - return LogoutRequest.logout(request) - }) - } - - static fromParams (req, res) { - let options = { - request: req, - response: res - } - - return new LogoutRequest(options) - } - - static logout (request) { - debug(`Logging out user ${request.request.session.userId}`) - - request.clearUserSession() - request.redirectToGoodbye() - } - - clearUserSession () { - let session = this.request.session - - session.accessToken = '' - session.refreshToken = '' - session.userId = '' - session.identified = false - session.subject = '' - } - - redirectToGoodbye () { - this.response.redirect('/goodbye') - } -} - -module.exports = LogoutRequest diff --git a/package.json b/package.json index 8dc092876..86a07d7f5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.0.6", + "oidc-auth-manager": "^0.1.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", @@ -92,9 +92,8 @@ "scripts": { "solid": "node ./bin/solid.js", "standard": "standard", - "mocha": "nyc mocha ./test/*.js", - "test": "npm run standard && npm run mocha", "mocha": "nyc mocha ./test/**/*.js", + "test": "npm run standard && npm run mocha", "test-integration": "mocha ./test/integration/*.js", "test-unit": "mocha ./test/unit/*.js", "test-debug": "DEBUG='solid:*' ./node_modules/mocha/bin/mocha ./test/*.js", diff --git a/test/unit/logout-request.js b/test/unit/logout-request.js deleted file mode 100644 index a5fdb32a3..000000000 --- a/test/unit/logout-request.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const HttpMocks = require('node-mocks-http') - -const LogoutRequest = require('../../lib/requests/logout-request') - -describe('LogoutRequest', () => { - it('should clear user session properties', () => { - let req = { - session: { - userId: 'https://alice.example.com/#me', - identified: true, - accessToken: {}, - refreshToken: {}, - subject: {} - } - } - let res = HttpMocks.createResponse() - - return LogoutRequest.handle(req, res) - .then(() => { - let session = req.session - expect(session.userId).to.be.empty - }) - }) - - it('should redirect to /goodbye', () => { - let req = { session: {} } - let res = HttpMocks.createResponse() - - return LogoutRequest.handle(req, res) - .then(() => { - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal('/goodbye') - }) - }) -}) From 2921a540c075a218f8b2e6fb69ee10f8d2b8d984 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 16 Mar 2017 16:24:34 -0400 Subject: [PATCH 006/178] Add --db-path config option --- .gitignore | 2 +- bin/lib/options.js | 8 +++++++- package.json | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ebebea82f..aec3e06fa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ inbox .acl config.json settings -db/ +.db/ .nyc_output coverage diff --git a/bin/lib/options.js b/bin/lib/options.js index abbf48927..36f646219 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -46,6 +46,12 @@ module.exports = [ default: '/', prompt: true }, + { + name: 'db-path', + question: 'Path to the server metadata db directory (for users/apps etc)', + default: './.db', + prompt: true + }, { name: 'auth', help: 'Pick an authentication strategy for WebID: `tls` or `oidc`', @@ -59,6 +65,7 @@ module.exports = [ default: 'WebID-OpenID Connect', filter: (value) => { if (value === 'WebID-TLS') return 'tls' + if (value === 'WebID-OpenID Connect') return 'oidc' }, when: (answers) => { return answers.webid @@ -133,7 +140,6 @@ module.exports = [ default: '/proxy', prompt: true }, - { name: 'file-browser', help: 'Type the URL of default app to use for browsing files (or use default)', diff --git a/package.json b/package.json index 86a07d7f5..34eecbca7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "express-session": "^1.11.3", "extend": "^3.0.0", "from2": "^2.1.0", - "fs-extra": "^0.30.0", + "fs-extra": "^2.1.0", "glob": "^7.1.1", "handlebars": "^4.0.6", "inquirer": "^1.0.2", @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.1.0", + "oidc-auth-manager": "^0.1.1", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", From 2b77734d83ce9bd16d19a7d25e8e0a526489715c Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 16 Mar 2017 16:27:58 -0400 Subject: [PATCH 007/178] Move oidc-manager test from unit to integration --- test/integration/oidc-manager.js | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/integration/oidc-manager.js diff --git a/test/integration/oidc-manager.js b/test/integration/oidc-manager.js new file mode 100644 index 000000000..e57ad2375 --- /dev/null +++ b/test/integration/oidc-manager.js @@ -0,0 +1,39 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const path = require('path') +const fs = require('fs-extra') + +const OidcManager = require('../../lib/models/oidc-manager') +const SolidHost = require('../../lib/models/solid-host') + +const dbPath = path.join(__dirname, '../resources/.db') + +describe('OidcManager', () => { + beforeEach(() => { + fs.removeSync(dbPath) + }) + + describe('fromServerConfig()', () => { + it('should result in an initialized oidc object', () => { + let serverUri = 'https://localhost:8443' + let host = SolidHost.from({ serverUri }) + + let saltRounds = 5 + let argv = { + host, + dbPath, + saltRounds + } + + let oidc = OidcManager.fromServerConfig(argv) + + expect(oidc.rs.defaults.query).to.be.true + expect(oidc.clients.store.backend.path.endsWith('db/oidc/rp/clients')) + expect(oidc.provider.issuer).to.equal(serverUri) + expect(oidc.users.backend.path.endsWith('db/oidc/users')) + expect(oidc.users.saltRounds).to.equal(saltRounds) + }) + }) +}) From 5d1b3aec830dc278989a54c6dd6aae79df5a0151 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 16 Mar 2017 16:28:30 -0400 Subject: [PATCH 008/178] Refactor config defaults. (webid: true by default) --- config/defaults.js | 8 +++++--- lib/create-app.js | 4 +++- lib/models/account-manager.js | 2 +- lib/models/solid-host.js | 4 ++-- test/integration/account-creation-oidc.js | 6 +++++- test/integration/authentication-oidc.js | 4 ++-- test/integration/errors.js | 6 ++++-- test/integration/formats.js | 3 ++- test/integration/http.js | 3 ++- test/integration/ldp.js | 3 ++- test/integration/params.js | 4 ++-- test/integration/patch-2.js | 3 ++- test/integration/patch.js | 3 ++- test/integration/proxy.js | 3 ++- test/unit/create-account-request.js | 2 +- test/unit/solid-host.js | 4 ++-- 16 files changed, 39 insertions(+), 23 deletions(-) diff --git a/config/defaults.js b/config/defaults.js index 79b39c9e3..f6fdcc10b 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -1,7 +1,9 @@ 'use strict' module.exports = { - 'AUTH_METHOD': 'tls', - 'DEFAULT_PORT': 8443, - 'DEFAULT_URI': 'https://localhost:8443' // default serverUri + 'auth': 'tls', + 'dbPath': './.db', + 'port': 8443, + 'serverUri': 'https://localhost:8443', + 'webid': true } diff --git a/lib/create-app.js b/lib/create-app.js index a3d3eadd6..998163465 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -35,8 +35,10 @@ const corsSettings = cors({ }) function createApp (argv = {}) { + argv = Object.assign({}, defaults, argv) + argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri }) - argv.auth = argv.auth || defaults.AUTH_METHOD + argv.templates = initTemplates() let ldp = new LDP(argv) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 26443184c..9d08cb944 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -41,7 +41,7 @@ class AccountManager { } this.host = options.host this.emailService = options.emailService - this.authMethod = options.authMethod || defaults.AUTH_METHOD + this.authMethod = options.authMethod || defaults.auth this.multiUser = options.multiUser || false this.store = options.store this.pathCard = options.pathCard || 'profile/card' diff --git a/lib/models/solid-host.js b/lib/models/solid-host.js index 40abefea8..1a699604e 100644 --- a/lib/models/solid-host.js +++ b/lib/models/solid-host.js @@ -18,8 +18,8 @@ class SolidHost { * server is listening on, e.g. `https://databox.me` */ constructor (options = {}) { - this.port = options.port || defaults.DEFAULT_PORT - this.serverUri = options.serverUri || defaults.DEFAULT_URI + this.port = options.port || defaults.port + this.serverUri = options.serverUri || defaults.serverUri this.parsedUri = url.parse(this.serverUri) this.host = this.parsedUri.host diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc.js index 7a996c458..19294647a 100644 --- a/test/integration/account-creation-oidc.js +++ b/test/integration/account-creation-oidc.js @@ -5,6 +5,7 @@ const $rdf = require('rdflib') const { rm, read } = require('../test-utils') const ldnode = require('../../index') const path = require('path') +const fs = require('fs-extra') describe('AccountManager (OIDC account creation tests)', function () { this.timeout(10000) @@ -13,6 +14,8 @@ describe('AccountManager (OIDC account creation tests)', function () { var serverUri = 'https://localhost:3457' var host = 'localhost:3457' var ldpHttpsServer + let dbPath = path.join(__dirname, '../resources/.db') + var ldp = ldnode.createServer({ root: path.join(__dirname, '../resources/accounts/'), sslKey: path.join(__dirname, '../keys/key.pem'), @@ -21,7 +24,7 @@ describe('AccountManager (OIDC account creation tests)', function () { webid: true, idp: true, strictOrigin: true, - dbPath: path.join(__dirname, '../resources/db/oidc'), + dbPath, serverUri }) @@ -31,6 +34,7 @@ describe('AccountManager (OIDC account creation tests)', function () { after(function () { if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(dbPath) }) var server = supertest(serverUri) diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc.js index 0c046fd69..b99e0369b 100644 --- a/test/integration/authentication-oidc.js +++ b/test/integration/authentication-oidc.js @@ -67,8 +67,8 @@ describe('Authentication API (OIDC)', () => { after(() => { if (aliceServer) aliceServer.close() if (bobServer) bobServer.close() - fs.removeSync(aliceDbPath) - fs.removeSync(bobDbPath) + // fs.removeSync(aliceDbPath) + // fs.removeSync(bobDbPath) }) describe('Provider Discovery (POST /api/auth/discover)', () => { diff --git a/test/integration/errors.js b/test/integration/errors.js index 8b97522b5..eb67ccd5c 100644 --- a/test/integration/errors.js +++ b/test/integration/errors.js @@ -13,14 +13,16 @@ describe('Error pages', function () { // LDP with error pages var errorLdp = ldnode({ root: path.join(__dirname, '../resources'), - errorPages: path.join(__dirname, '../resources/errorPages') + errorPages: path.join(__dirname, '../resources/errorPages'), + webid: false }) var errorServer = supertest(errorLdp) // LDP with no error pages var noErrorLdp = ldnode({ root: path.join(__dirname, '../resources'), - noErrorPages: true + noErrorPages: true, + webid: false }) var noErrorServer = supertest(noErrorLdp) diff --git a/test/integration/formats.js b/test/integration/formats.js index 57443f67d..3c776599d 100644 --- a/test/integration/formats.js +++ b/test/integration/formats.js @@ -4,7 +4,8 @@ var path = require('path') describe('formats', function () { var ldp = ldnode.createServer({ - root: path.join(__dirname, '../resources') + root: path.join(__dirname, '../resources'), + webid: false }) var server = supertest(ldp) diff --git a/test/integration/http.js b/test/integration/http.js index e2d44b7c7..e473d34b0 100644 --- a/test/integration/http.js +++ b/test/integration/http.js @@ -12,7 +12,8 @@ var ldpServer = ldnode.createServer({ live: true, dataBrowserPath: 'default', root: path.join(__dirname, '../resources'), - auth: 'oidc' + auth: 'oidc', + webid: false }) var server = supertest(ldpServer) var assert = require('chai').assert diff --git a/test/integration/ldp.js b/test/integration/ldp.js index 508e17bcc..de496a6a3 100644 --- a/test/integration/ldp.js +++ b/test/integration/ldp.js @@ -14,7 +14,8 @@ var fs = require('fs') describe('LDP', function () { var ldp = new LDP({ - root: path.join(__dirname, '..') + root: path.join(__dirname, '..'), + webid: false }) describe('readFile', function () { diff --git a/test/integration/params.js b/test/integration/params.js index 9b4942487..fdfe53872 100644 --- a/test/integration/params.js +++ b/test/integration/params.js @@ -30,7 +30,7 @@ describe('LDNODE params', function () { describe('root', function () { describe('not passed', function () { - var ldp = ldnode() + var ldp = ldnode({ webid: false }) var server = supertest(ldp) it('should fallback on current working directory', function () { @@ -55,7 +55,7 @@ describe('LDNODE params', function () { }) describe('passed', function () { - var ldp = ldnode({root: './test/resources/'}) + var ldp = ldnode({root: './test/resources/', webid: false}) var server = supertest(ldp) it('should fallback on current working directory', function () { diff --git a/test/integration/patch-2.js b/test/integration/patch-2.js index af716130f..acfeb71cf 100644 --- a/test/integration/patch-2.js +++ b/test/integration/patch-2.js @@ -13,7 +13,8 @@ describe('PATCH', function () { // Starting LDP var ldp = ldnode({ root: path.join(__dirname, '../resources/sampleContainer'), - mount: '/test' + mount: '/test', + webid: false }) var server = supertest(ldp) diff --git a/test/integration/patch.js b/test/integration/patch.js index 68fc9ad1d..c5247c799 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -13,7 +13,8 @@ describe('PATCH', function () { // Starting LDP var ldp = ldnode({ root: path.join(__dirname, '../resources/sampleContainer'), - mount: '/test' + mount: '/test', + webid: false }) var server = supertest(ldp) diff --git a/test/integration/proxy.js b/test/integration/proxy.js index c7eb2c341..82756b4c6 100644 --- a/test/integration/proxy.js +++ b/test/integration/proxy.js @@ -9,7 +9,8 @@ var ldnode = require('../../index') describe('proxy', () => { var ldp = ldnode({ root: path.join(__dirname, '../resources'), - proxy: '/proxy' + proxy: '/proxy', + webid: false }) var server = supertest(ldp) diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index 5cf73e5c4..b3eb05cc7 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -63,7 +63,7 @@ describe('CreateAccountRequest', () => { describe('createAccount()', () => { it('should return a 400 error if account already exists', done => { - let locals = { authMethod: defaults.AUTH_METHOD } + let locals = { authMethod: defaults.auth } let aliceData = { username: 'alice' } let req = { app: { locals }, body: aliceData } let accountManager = AccountManager.from({ host }) diff --git a/test/unit/solid-host.js b/test/unit/solid-host.js index e1c15a2dc..078d46b1e 100644 --- a/test/unit/solid-host.js +++ b/test/unit/solid-host.js @@ -21,8 +21,8 @@ describe('SolidHost', () => { it('should init to default port and serverUri values', () => { let host = SolidHost.from({}) - expect(host.port).to.equal(defaults.DEFAULT_PORT) - expect(host.serverUri).to.equal(defaults.DEFAULT_URI) + expect(host.port).to.equal(defaults.port) + expect(host.serverUri).to.equal(defaults.serverUri) }) }) From 6bb6f38058ae1adb6f85edbca143950552c7ecf7 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 16 Mar 2017 17:04:52 -0400 Subject: [PATCH 009/178] Move default-account-template/ to default-templates/new-account --- .../new-account}/.acl | 0 .../new-account}/.meta | 0 .../new-account}/.meta.acl | 0 .../new-account}/favicon.ico | Bin .../new-account}/favicon.ico.acl | 0 .../new-account}/inbox/.acl | 0 .../new-account}/profile/card | 0 .../new-account}/profile/card.acl | 0 .../new-account}/settings/.acl | 0 .../new-account}/settings/prefs.ttl | 0 .../new-account}/settings/privateTypeIndex.ttl | 0 .../new-account}/settings/publicTypeIndex.ttl | 0 .../new-account}/settings/publicTypeIndex.ttl.acl | 0 lib/create-app.js | 2 +- lib/models/account-manager.js | 2 +- lib/models/account-template.js | 2 +- test/integration/account-manager.js | 4 +--- test/integration/account-template.js | 2 +- test/unit/account-manager.js | 2 +- 19 files changed, 6 insertions(+), 8 deletions(-) rename {default-account-template => default-templates/new-account}/.acl (100%) rename {default-account-template => default-templates/new-account}/.meta (100%) rename {default-account-template => default-templates/new-account}/.meta.acl (100%) rename {default-account-template => default-templates/new-account}/favicon.ico (100%) rename {default-account-template => default-templates/new-account}/favicon.ico.acl (100%) rename {default-account-template => default-templates/new-account}/inbox/.acl (100%) rename {default-account-template => default-templates/new-account}/profile/card (100%) rename {default-account-template => default-templates/new-account}/profile/card.acl (100%) rename {default-account-template => default-templates/new-account}/settings/.acl (100%) rename {default-account-template => default-templates/new-account}/settings/prefs.ttl (100%) rename {default-account-template => default-templates/new-account}/settings/privateTypeIndex.ttl (100%) rename {default-account-template => default-templates/new-account}/settings/publicTypeIndex.ttl (100%) rename {default-account-template => default-templates/new-account}/settings/publicTypeIndex.ttl.acl (100%) diff --git a/default-account-template/.acl b/default-templates/new-account/.acl similarity index 100% rename from default-account-template/.acl rename to default-templates/new-account/.acl diff --git a/default-account-template/.meta b/default-templates/new-account/.meta similarity index 100% rename from default-account-template/.meta rename to default-templates/new-account/.meta diff --git a/default-account-template/.meta.acl b/default-templates/new-account/.meta.acl similarity index 100% rename from default-account-template/.meta.acl rename to default-templates/new-account/.meta.acl diff --git a/default-account-template/favicon.ico b/default-templates/new-account/favicon.ico similarity index 100% rename from default-account-template/favicon.ico rename to default-templates/new-account/favicon.ico diff --git a/default-account-template/favicon.ico.acl b/default-templates/new-account/favicon.ico.acl similarity index 100% rename from default-account-template/favicon.ico.acl rename to default-templates/new-account/favicon.ico.acl diff --git a/default-account-template/inbox/.acl b/default-templates/new-account/inbox/.acl similarity index 100% rename from default-account-template/inbox/.acl rename to default-templates/new-account/inbox/.acl diff --git a/default-account-template/profile/card b/default-templates/new-account/profile/card similarity index 100% rename from default-account-template/profile/card rename to default-templates/new-account/profile/card diff --git a/default-account-template/profile/card.acl b/default-templates/new-account/profile/card.acl similarity index 100% rename from default-account-template/profile/card.acl rename to default-templates/new-account/profile/card.acl diff --git a/default-account-template/settings/.acl b/default-templates/new-account/settings/.acl similarity index 100% rename from default-account-template/settings/.acl rename to default-templates/new-account/settings/.acl diff --git a/default-account-template/settings/prefs.ttl b/default-templates/new-account/settings/prefs.ttl similarity index 100% rename from default-account-template/settings/prefs.ttl rename to default-templates/new-account/settings/prefs.ttl diff --git a/default-account-template/settings/privateTypeIndex.ttl b/default-templates/new-account/settings/privateTypeIndex.ttl similarity index 100% rename from default-account-template/settings/privateTypeIndex.ttl rename to default-templates/new-account/settings/privateTypeIndex.ttl diff --git a/default-account-template/settings/publicTypeIndex.ttl b/default-templates/new-account/settings/publicTypeIndex.ttl similarity index 100% rename from default-account-template/settings/publicTypeIndex.ttl rename to default-templates/new-account/settings/publicTypeIndex.ttl diff --git a/default-account-template/settings/publicTypeIndex.ttl.acl b/default-templates/new-account/settings/publicTypeIndex.ttl.acl similarity index 100% rename from default-account-template/settings/publicTypeIndex.ttl.acl rename to default-templates/new-account/settings/publicTypeIndex.ttl.acl diff --git a/lib/create-app.js b/lib/create-app.js index 998163465..bf5744265 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -105,7 +105,7 @@ function createApp (argv = {}) { function initTemplates () { let accountTemplatePath = ensureTemplateCopiedTo( - '../default-account-template', + '../default-templates/new-account', '../config/account-template' ) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 9d08cb944..2d5d1800b 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -46,7 +46,7 @@ class AccountManager { this.store = options.store this.pathCard = options.pathCard || 'profile/card' this.suffixURI = options.suffixURI || '#me' - this.accountTemplatePath = options.accountTemplatePath || './default-account-template/' + this.accountTemplatePath = options.accountTemplatePath || './default-templates/new-account/' } /** diff --git a/lib/models/account-template.js b/lib/models/account-template.js index af9956ca0..b5c882048 100644 --- a/lib/models/account-template.js +++ b/lib/models/account-template.js @@ -12,7 +12,7 @@ const TEMPLATE_FILES = [ 'card' ] /** * Performs account folder initialization from an account template - * (see `./default-account-template/`, for example). + * (see `./default-templates/new-account/`, for example). * * @class AccountTemplate */ diff --git a/test/integration/account-manager.js b/test/integration/account-manager.js index 23d8ce332..264aa8c27 100644 --- a/test/integration/account-manager.js +++ b/test/integration/account-manager.js @@ -11,9 +11,7 @@ const SolidHost = require('../../lib/models/solid-host') const AccountManager = require('../../lib/models/account-manager') const testAccountsDir = path.join(__dirname, '../resources/accounts') -const accountTemplatePath = path.join(__dirname, '../../default-account-template') - -console.log('accountTemplatePath: ', accountTemplatePath) +const accountTemplatePath = path.join(__dirname, '../../default-templates/new-account') var host diff --git a/test/integration/account-template.js b/test/integration/account-template.js index 1cf3bfece..ff5cdcc94 100644 --- a/test/integration/account-template.js +++ b/test/integration/account-template.js @@ -10,7 +10,7 @@ chai.should() const AccountTemplate = require('../../lib/models/account-template') -const templatePath = path.join(__dirname, '../../default-account-template') +const templatePath = path.join(__dirname, '../../default-templates/new-account') const accountPath = path.join(__dirname, '../resources/new-account') describe('AccountTemplate', () => { diff --git a/test/unit/account-manager.js b/test/unit/account-manager.js index e7e031698..79190e213 100644 --- a/test/unit/account-manager.js +++ b/test/unit/account-manager.js @@ -274,7 +274,7 @@ describe('AccountManager', () => { it('should create an account directory', () => { let multiUser = true - let accountTemplatePath = path.join(__dirname, '../../default-account-template') + let accountTemplatePath = path.join(__dirname, '../../default-templates/new-account') let store = new LDP({ root: testAccountsDir, idp: multiUser }) let options = { host, multiUser, store, accountTemplatePath } let accountManager = AccountManager.from(options) From 6614f239e9a4e443f79f1dc54f81e96717806364 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 16 Mar 2017 17:12:14 -0400 Subject: [PATCH 010/178] Move default-email-templates/ to default-templates/emails --- .../emails}/welcome.js | 0 lib/create-app.js | 4 ++-- test/email-welcome.js | 2 +- test/unit/email-service.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename {default-email-templates => default-templates/emails}/welcome.js (100%) diff --git a/default-email-templates/welcome.js b/default-templates/emails/welcome.js similarity index 100% rename from default-email-templates/welcome.js rename to default-templates/emails/welcome.js diff --git a/lib/create-app.js b/lib/create-app.js index bf5744265..0f698d089 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -110,7 +110,7 @@ function initTemplates () { ) let emailTemplatesPath = ensureTemplateCopiedTo( - '../default-email-templates', + '../default-templates/emails', '../config/email-templates' ) @@ -125,7 +125,7 @@ function initTemplates () { * default templates. * * @param defaultTemplateDir {string} Path to a default template directory, - * relative to `lib/`. For example, '../default-email-templates' contains + * relative to `lib/`. For example, '../default-templates/emails' contains * various email templates pre-defined by the Solid dev team. * * @param configTemplateDir {string} Path to a template directory customized diff --git a/test/email-welcome.js b/test/email-welcome.js index cb58286a9..33a318c07 100644 --- a/test/email-welcome.js +++ b/test/email-welcome.js @@ -12,7 +12,7 @@ const SolidHost = require('../lib/models/solid-host') const AccountManager = require('../lib/models/account-manager') const EmailService = require('../lib/models/email-service') -const templatePath = path.join(__dirname, '../default-email-templates') +const templatePath = path.join(__dirname, '../default-templates/emails') var host, accountManager, emailService diff --git a/test/unit/email-service.js b/test/unit/email-service.js index 2bd68d69c..97de0ca80 100644 --- a/test/unit/email-service.js +++ b/test/unit/email-service.js @@ -7,7 +7,7 @@ const sinonChai = require('sinon-chai') chai.use(sinonChai) chai.should() -const templatePath = path.join(__dirname, '../../default-email-templates') +const templatePath = path.join(__dirname, '../../default-templates/emails') describe('Email Service', function () { describe('EmailService constructor', () => { From 89fe55ead9126e7fb65e163af700f6bf7800c296 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 17 Mar 2017 11:20:38 -0400 Subject: [PATCH 011/178] Add --config-path parameter (for default templates and apps) --- .gitignore | 2 ++ bin/lib/options.js | 6 ++++++ config/defaults.js | 1 + lib/create-app.js | 54 +++++++++++++++++++++++++--------------------- lib/ldp.js | 2 ++ 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index aec3e06fa..6e75eafe7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ profile inbox .acl config.json +config/templates +config/apps settings .db/ .nyc_output diff --git a/bin/lib/options.js b/bin/lib/options.js index 36f646219..a712a537c 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -46,6 +46,12 @@ module.exports = [ default: '/', prompt: true }, + { + name: 'config-path', + question: 'Path to the config directory (for example: /etc/solid-server)', + default: './config', + prompt: true + }, { name: 'db-path', question: 'Path to the server metadata db directory (for users/apps etc)', diff --git a/config/defaults.js b/config/defaults.js index f6fdcc10b..fcd7c1786 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -2,6 +2,7 @@ module.exports = { 'auth': 'tls', + 'configPath': './config', 'dbPath': './.db', 'port': 8443, 'serverUri': 'https://localhost:8443', diff --git a/lib/create-app.js b/lib/create-app.js index 0f698d089..f0e13def9 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -35,11 +35,14 @@ const corsSettings = cors({ }) function createApp (argv = {}) { + // Override default configs (defaults) with passed-in params (argv) argv = Object.assign({}, defaults, argv) argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri }) - argv.templates = initTemplates() + let configPath = initConfigPath(argv) + + argv.templates = initTemplateDirs(configPath) let ldp = new LDP(argv) let app = express() @@ -103,15 +106,22 @@ function createApp (argv = {}) { return app } -function initTemplates () { - let accountTemplatePath = ensureTemplateCopiedTo( - '../default-templates/new-account', - '../config/account-template' +function initConfigPath (argv) { + let configPath = path.resolve(argv.configPath) + fs.mkdirp(configPath) + + return configPath +} + +function initTemplateDirs (configPath) { + let accountTemplatePath = ensureDirCopy( + './default-templates/new-account', + path.join(configPath, 'templates', 'new-account') ) - let emailTemplatesPath = ensureTemplateCopiedTo( - '../default-templates/emails', - '../config/email-templates' + let emailTemplatesPath = ensureDirCopy( + './default-templates/emails', + path.join(configPath, 'templates', 'emails') ) return { @@ -121,29 +131,25 @@ function initTemplates () { } /** - * Ensures that a template directory has been initialized in `config/` from - * default templates. + * Ensures that a directory has been copied / initialized. Used to ensure that + * account templates, email templates and default apps have been copied from + * their defaults to the customizable config directory, at server startup. * - * @param defaultTemplateDir {string} Path to a default template directory, - * relative to `lib/`. For example, '../default-templates/emails' contains - * various email templates pre-defined by the Solid dev team. + * @param fromDir {string} Path to copy from (defaults) * - * @param configTemplateDir {string} Path to a template directory customized - * to this particular installation (relative to `lib/`). Server operators - * are encouraged to override/customize these templates in the `config/` - * directory. + * @param toDir {string} Path to copy to (customizable config) * - * @return {string} Returns the absolute path to the customizable template copy + * @return {string} Returns the absolute path for `toDir` */ -function ensureTemplateCopiedTo (defaultTemplateDir, configTemplateDir) { - let configTemplatePath = path.join(__dirname, configTemplateDir) - let defaultTemplatePath = path.join(__dirname, defaultTemplateDir) +function ensureDirCopy (fromDir, toDir) { + fromDir = path.resolve(fromDir) + toDir = path.resolve(toDir) - if (!fs.existsSync(configTemplatePath)) { - fs.copySync(defaultTemplatePath, configTemplatePath) + if (!fs.existsSync(toDir)) { + fs.copySync(fromDir, toDir) } - return configTemplatePath + return toDir } /** diff --git a/lib/ldp.js b/lib/ldp.js index 8331188df..e7b779069 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -84,6 +84,8 @@ class LDP { } debug.settings('Auth method: ' + this.auth) + debug.settings('Db path: ' + this.dbPath) + debug.settings('Config path: ' + this.configPath) debug.settings('Suffix Acl: ' + this.suffixAcl) debug.settings('Suffix Meta: ' + this.suffixMeta) debug.settings('Filesystem Root: ' + this.root) From d608422f61b9e0f775da9f635f1361633e897b54 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 17 Mar 2017 12:01:12 -0400 Subject: [PATCH 012/178] Serve public common/ dir (for shared CSS files, etc) --- lib/create-app.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/create-app.js b/lib/create-app.js index f0e13def9..e660bac03 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -40,12 +40,15 @@ function createApp (argv = {}) { argv.host = SolidHost.from({ port: argv.port, serverUri: argv.serverUri }) - let configPath = initConfigPath(argv) + const configPath = initConfigPath(argv) argv.templates = initTemplateDirs(configPath) - let ldp = new LDP(argv) - let app = express() + const ldp = new LDP(argv) + const app = express() + + // Serve the public 'common' directory (for shared CSS files, etc) + app.use('/common', express.static('common')) initAppLocals(app, argv, ldp) From 73d3c238726981ff8fa8c1652d1ecd5d915b6b0b Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 17 Mar 2017 12:01:49 -0400 Subject: [PATCH 013/178] Add boostrap.min.css v3.3.7 to common/css/ --- common/css/bootstrap.min.css | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 common/css/bootstrap.min.css diff --git a/common/css/bootstrap.min.css b/common/css/bootstrap.min.css new file mode 100644 index 000000000..ed3905e0e --- /dev/null +++ b/common/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file From 4bbf1310583cef26d795e65f49944a8e825d9b58 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 17 Mar 2017 12:58:32 -0400 Subject: [PATCH 014/178] Make views/ customizable like templates --- .gitignore | 2 +- {views => default-views}/auth/consent.hbs | 0 default-views/auth/select-provider.hbs | 26 +++++++++++++++++++++++ lib/api/authn/webid-oidc.js | 2 +- lib/create-app.js | 26 ++++++++++++----------- 5 files changed, 42 insertions(+), 14 deletions(-) rename {views => default-views}/auth/consent.hbs (100%) create mode 100644 default-views/auth/select-provider.hbs diff --git a/.gitignore b/.gitignore index 6e75eafe7..f3b52ae7a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ inbox .acl config.json config/templates -config/apps +config/views settings .db/ .nyc_output diff --git a/views/auth/consent.hbs b/default-views/auth/consent.hbs similarity index 100% rename from views/auth/consent.hbs rename to default-views/auth/consent.hbs diff --git a/default-views/auth/select-provider.hbs b/default-views/auth/select-provider.hbs new file mode 100644 index 000000000..a104ce19d --- /dev/null +++ b/default-views/auth/select-provider.hbs @@ -0,0 +1,26 @@ + + + + + + Select Provider + + + +
+
+

Select Provider

+
+
+
+
+
+ + + +
+ +
+
+ + diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 2e2a1ae0e..36e69e54c 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -36,7 +36,7 @@ function middleware (oidc) { // User-facing Authentication API router.get('/api/auth/discover', (req, res) => { - res.sendFile('discover-provider.html', { root: './static/oidc/' }) + res.render('auth/select-provider') }) router.post('/api/auth/discover', bodyParser, discoverProvider) diff --git a/lib/create-app.js b/lib/create-app.js index e660bac03..e0e178fd7 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -50,6 +50,11 @@ function createApp (argv = {}) { // Serve the public 'common' directory (for shared CSS files, etc) app.use('/common', express.static('common')) + const viewsPath = initDefaultViews(configPath) + app.set('views', viewsPath) + app.engine('.hbs', handlebars({ extname: '.hbs' })) + app.set('view engine', '.hbs') + initAppLocals(app, argv, ldp) initHeaders(app) @@ -116,6 +121,15 @@ function initConfigPath (argv) { return configPath } +function initDefaultViews (configPath) { + let defaultViewsPath = path.resolve('./default-views') + let viewsPath = path.join(configPath, 'views') + + ensureDirCopy(defaultViewsPath, viewsPath) + + return viewsPath +} + function initTemplateDirs (configPath) { let accountTemplatePath = ensureDirCopy( './default-templates/new-account', @@ -194,7 +208,6 @@ function initAuthentication (argv, app) { // This is where the OIDC-enabled signup/signin apps live app.use('/', express.static(path.join(__dirname, '../static/oidc'))) - initAuthTemplates(app) // Initialize the WebId-OIDC authentication routes/api, including: // user-facing Solid endpoints (/login, /logout, /api/auth/discover) @@ -211,17 +224,6 @@ function initAuthentication (argv, app) { } } -/** - * Sets up Handlebars to be used for auth-related views. - * - * @param app {Function} Express.js app instance - */ -function initAuthTemplates (app) { - app.set('views', path.join(__dirname, '../views')) - app.engine('.hbs', handlebars({ extname: '.hbs' })) - app.set('view engine', '.hbs') -} - /** * Sets up headers common to all Solid requests (CORS-related, Allow, etc). * From fa2b96c925a948aa9cbcd9b6f319bc5ad0fe56a2 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 17 Mar 2017 14:08:46 -0400 Subject: [PATCH 015/178] Rename DiscoverProviderRequest to SelectProviderRequest --- .gitignore | 22 +++++++------- default-views/auth/consent.hbs | 2 +- default-views/auth/select-provider.hbs | 2 +- lib/api/authn/webid-oidc.js | 14 ++++----- lib/create-app.js | 2 +- lib/handlers/error-pages.js | 2 +- lib/models/oidc-manager.js | 1 + ...-request.js => select-provider-request.js} | 24 +++++++-------- test/integration/authentication-oidc.js | 30 ++++++++++--------- ...-request.js => select-provider-request.js} | 20 ++++++------- 10 files changed, 61 insertions(+), 58 deletions(-) rename lib/requests/{discover-provider-request.js => select-provider-request.js} (89%) rename test/unit/{discover-provider-request.js => select-provider-request.js} (74%) diff --git a/.gitignore b/.gitignore index f3b52ae7a..b39a1ed16 100644 --- a/.gitignore +++ b/.gitignore @@ -3,16 +3,16 @@ node_modules/ *.swp .tern-port npm-debug.log -config/account-template -config/email-templates -accounts -profile -inbox -.acl -config.json -config/templates -config/views -settings -.db/ +/config/account-template +/config/email-templates +/accounts +/profile +/inbox +/.acl +/config.json +/config/templates +/config/views +/settings +/.db .nyc_output coverage diff --git a/default-views/auth/consent.hbs b/default-views/auth/consent.hbs index 11d425fd2..615aa74b0 100644 --- a/default-views/auth/consent.hbs +++ b/default-views/auth/consent.hbs @@ -5,7 +5,7 @@ {{title}} - + + + +
+

Authorize app to use your Web ID?

+
+
+
+ + + + + + + + + + +
+
+ + diff --git a/test/resources/accounts-acl/config/views/auth/goodbye.hbs b/test/resources/accounts-acl/config/views/auth/goodbye.hbs new file mode 100644 index 000000000..305cccac0 --- /dev/null +++ b/test/resources/accounts-acl/config/views/auth/goodbye.hbs @@ -0,0 +1,20 @@ + + + + + + Logged Out + + + +
+

You have logged out.

+
+
+
+ +
+
+ + diff --git a/test/resources/accounts-acl/config/views/auth/login.hbs b/test/resources/accounts-acl/config/views/auth/login.hbs new file mode 100644 index 000000000..9c167bd63 --- /dev/null +++ b/test/resources/accounts-acl/config/views/auth/login.hbs @@ -0,0 +1,51 @@ + + + + + + Login + + + +
+

Login

+
+
+
+
+ {{#if error}} +
+
+

{{error}}

+
+
+ {{/if}} +
+
+ + +
+
+
+
+ + +
+
+ + + + + + + +
+ + +
Don't have an account? + Register +
+
+
+ + diff --git a/test/resources/accounts-acl/config/views/auth/select-provider.hbs b/test/resources/accounts-acl/config/views/auth/select-provider.hbs new file mode 100644 index 000000000..2c7fa1382 --- /dev/null +++ b/test/resources/accounts-acl/config/views/auth/select-provider.hbs @@ -0,0 +1,27 @@ + + + + + + Select Provider + + + +
+
+

Select Provider

+
+
+
+
+
+ + + +
+ +
+
+ + diff --git a/test/resources/accounts-acl/db/oidc/op/clients/_key_7f1be9aa47bb1372b3bc75e91da352b4.json b/test/resources/accounts-acl/db/oidc/op/clients/_key_7f1be9aa47bb1372b3bc75e91da352b4.json new file mode 100644 index 000000000..3277453cb --- /dev/null +++ b/test/resources/accounts-acl/db/oidc/op/clients/_key_7f1be9aa47bb1372b3bc75e91da352b4.json @@ -0,0 +1 @@ +{"client_id":"7f1be9aa47bb1372b3bc75e91da352b4","client_secret":"c48b13e77bd58f8d7208f34cde22e898","redirect_uris":["https://localhost:7777/api/oidc/rp/https%3A%2F%2Flocalhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7777/goodbye"],"frontchannel_logout_session_required":false} \ No newline at end of file diff --git a/test/resources/accounts-acl/db/oidc/op/provider.json b/test/resources/accounts-acl/db/oidc/op/provider.json new file mode 100644 index 000000000..88504f9ec --- /dev/null +++ b/test/resources/accounts-acl/db/oidc/op/provider.json @@ -0,0 +1,411 @@ +{ + "issuer": "https://localhost:7777", + "authorization_endpoint": "https://localhost:7777/authorize", + "token_endpoint": "https://localhost:7777/token", + "userinfo_endpoint": "https://localhost:7777/userinfo", + "jwks_uri": "https://localhost:7777/jwks", + "registration_endpoint": "https://localhost:7777/register", + "response_types_supported": [ + "code", + "code token", + "code id_token", + "id_token", + "id_token token", + "code id_token token", + "none" + ], + "response_modes_supported": [ + "query", + "fragment" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "client_credentials" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "none" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256" + ], + "display_values_supported": [], + "claim_types_supported": [ + "normal" + ], + "claims_supported": "", + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": true, + "require_request_uri_registration": false, + "check_session_iframe": "https://localhost:7777/session", + "end_session_endpoint": "https://localhost:7777/logout", + "keys": { + "descriptor": { + "id_token": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + }, + "RS384": { + "alg": "RS384", + "modulusLength": 2048 + }, + "RS512": { + "alg": "RS512", + "modulusLength": 2048 + } + }, + "encryption": {} + }, + "token": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + }, + "RS384": { + "alg": "RS384", + "modulusLength": 2048 + }, + "RS512": { + "alg": "RS512", + "modulusLength": 2048 + } + }, + "encryption": {} + }, + "userinfo": { + "encryption": {} + }, + "register": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + } + } + } + }, + "jwks": { + "keys": [ + { + "kid": "ohtdSKLCdYs", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "gI5JLLhVFG8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "1ZHTLTyLbQs", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "AVS5efNiEEM", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "ZVFUPkFyy18", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "mROehy7CZO4", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "it1Z6EDEV5g", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + ] + }, + "id_token": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "v-cHHQPNDvo", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "d": "pv-ULqejr_iYV8Ipm2yTv3_Lnu0GnZrjB0eW8u1Tr0Z8LSlALWn5b0DOgFXcl6iRebym5M9Hs6qLeSlMS2a-1rM5HVUR_x_RuLwojHbXPXsct-raoymD66xs8iLJw1f3uF5RTpn2fkR1ycHww-bO92hUdx6Y5Rdqfk5ZkMncuRIJI4PHrYcSxaGogl5JNL_Bzza5Sb8-GGV0Ef5wB9S4CM2VUgLj2r5RzwpezcrIA0w9TnbtEdA5EEdHG997jgQhp-fSUPKMtKrRRFJy_JqIYRUi4SOLP_gJYO_qpJlb9pxVQMVnhhXTnso-pSCfsxCTxRjb176BahlG3kuNTiwXKQ", + "p": "5JrtuYCK4-apgRriDLC2_LpVjlnioLoHHUGyYh8SZPwpOzDoQI3EOIZyFM0X9hRMBWoNXjgCUGhdwwAfw24JgKSx_Obni3pRVz69skm-Ee1dCRlDGi91B9q3-cNJG0qJI9mIPIRp2PCCvXToC48PVDkBm3t7zdzRPaosu_YWkrM", + "q": "yI-68nioykS5WrcvjKpsGke7O7MZ22sj9EGtPBRgoxSrDzZK9MutnM_9_vMYPGZy1cN8Ade1-Jw7qA8w8ZESeu5E4cQkArgpdVG34EEDz61A5SYf4GkD-qJ803TxZcmfqfGX-REoKUNafLaNbhQsOHrhrdN2oH-CZq2KrVHCt2U", + "dp": "zMGn49sqi-5yLF0z00IE5GDReOsxfdyhuqa5bAGArErfc1De9dMEycxCKjd5GsQbQ042IwnvqK2SLbLSwGyyvjLF6Uu4YMlySb68khBS2iPMjPW_kJipLhvNZTxxIqykISQaTnobhGAH-kHYBWJhzIIy2lzECyOZlq3x23kTxtk", + "dq": "etoP2ZavTbbrEvZC2hdKQI7P0bHTlOP8EhJo2vRgfYSbg6XuJCTfI78EBrdBkT3v-aDUxQwtGywYHsmvYUlL2KE68FAE_uVv_70etO8eNogZyEOiIwQwu8XsUFrBw2fNtXuXa6lmwF_RfbMUzujsbWxX8PInKAjzB5Il8CS08UE", + "qi": "GBJ90AkXHhbgiL4yk9w6MtQxi1F8XRHBpG3t97Aj1we14pITY56vpEJi97gUjsRsH9DZqzIFV62CSF0VMWaxxRX3c6yuUtJMBSq9Skpvipjwatlz3jxHGP26IFSO9b-NpidM9_egK5mYlGuNY0N1CN-7Lw_Rpt8cvrvvi2tB41c", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "ohtdSKLCdYs", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS384": { + "privateJwk": { + "kid": "5Rhg743p3K8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "d": "vvMZLzmQ7APUR0Jz6YiBRdmSZVX-D5ZcRVXJvZbDYeLpuA7W6Nfqk3kKmNLJ-PbV1AQP86OypU4IHJJcLYP_VKpt8Xnq5GItqPZQmBtPRLMSzVF8_UIzS1xORKGkEIWGUy-gyfIWUHnfRnFS8l2tlgLE_5H12YMgg4AuJKY_WkxJSedTKwr4K0COthvbMREqIGbNg9JJhJh54K2FtuNNqn4iycaYCNveunWekRBMpzL2IGsjECGtI4NSrjtneWpIY71pggG87QGduYGVgbdBYFSnJlgbCjN7bQNzpI8v7uE4eM7q6tphJMasVjCS1TGIuNZDl_-vfyCySkNlSvIyAQ", + "p": "6sPjPxcGVwAX1ADLxs7YRN_1U1xYUV_UenzTAnaNac5W8s-AQDoW7_6oCD3s0EmBRWsT_jhGbDUyMgJa0ZASa3nJVqXdYTrrxaBcOktUpLvq2cRgcxLkH_CYdT6yQMeUIjnAg5z-Rkjg0lvWPvqi-IVKDcoFUuF2sjGJjeF9d3k", + "q": "5_m_mSjbVM9ZGvvr-XDAybD3z2JPft1PjCISHcNdTe0-gu4z7VXNnIgynhD0JIee8UpEnBrPFOd7raPxY-y4wdYF-zE3gvl9IOveG793uPctvbWtQSYpcZuPWodn8t-3LvZNq5kLZLCSUIrgTJiwIS7v5Ihc5fxVuyJSYHeBtWE", + "dp": "4yrZ4lqtT9JPPF3o0V-l9j-gbCGXdGZ-fGf85w1AmXmIuTwApiWPvHt2rUL-vC3kYP_UQNLDkkGHaMzOhKocqNMX-DhXl5YkPv-FPwNVzHHqNv7HNZK6HA37-LfKVNTKirPHjZOEmQ48PlGPZzGwMTsJBX7O1_xDlvpIWHoxpkE", + "dq": "KQC8HRZbrmH4HgzpaO3FJeFh7AY0hvgXV22uRhSCKYQFyJ7SDuFbto9cYxQcE1jlf0DhX7ZdZBSGh-qygDcXcSujYwMQDNaMh4UpfT4aq1cFfsLeHOXh7XLRo-7LMOLaPjLLB8nFeca8FgB2JRPYDgV94ac4xG4VuT4X0XVOOAE", + "qi": "gVHniXGKh_ewcrZRRei-ujdYm-htsGYGjmCyXXQ_RVJYz9tauSzmBQPGfE088Wp4ybyTv0exZ_MnizFDHIpP6TWt_Dg5uYWP2UHbKdwdAs8nA9NSXdUFtyE06HsYx-Rd8APYl6A0oCjENweAx7xq9R4zbdMdZpmpX8v2N5WSZN0", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "gI5JLLhVFG8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS512": { + "privateJwk": { + "kid": "WHTKUBTBjl0", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "d": "nRMhd1yDQ3PjLQpLSRnM3hEu5kfJBi41tX77GrchDgs36AocKsYwPqLKjB3FVcGRQpPbQamtv4ArmCzlQdW3uhIZRKhpqZ5Fwr_WG5uWyM_ZWL6b33n3KHVWONzSp1id9jmtoicSlQUANKVSw_CDqmlvbDiKrLpqEyCTkGClG1XCMpTRq0IA_D19ZORd3XvdBePN1H2djX9Lh6ODW39iVdoDkj8b46STakIbu9rHUwA8ZusGd671JnXB4OemX71MCi677_GN1r5buWc8puFV8mrv-kYfk4hPyXQqZAqo9AbgoNbRb62OoWhs5mzmPYoxLyGNeUOedqefmSCQbQl1gQ", + "p": "5yAwbWvBFS3Wtgd4ncPQRkEqPVjaKU3u5VWdytdZylkGNVfB95WJiBJmLa1_arlMmKLuZlHAzgNDmd7_R0F6Bd8_mLynaxk3MTzsrawk17HTXkPX5k9jm8XDc1F6wvK9kL2Xc41DCvalWt6QMQXzJdQNJWy-mJx0He5CrULMHNc", + "q": "zXJhNeBWNg8s9QGufel8Mlewbu_e2cQVsolZZOgXlkj8_IbeRzH0PeHbzbSmabv4tJ36X579ddK5MSpL81sZ5ZbuPFYVVJCb4jzVtDFfNcgkM0OfRj_2F_T1JI2H1WKwHowTyQiXVp8xrECUg0DzkMpH-lse7fkrrS0-Vne92ME", + "dp": "lF6Wl_efWJA3kF0Vcfmc_yygCAe87N0JqhEfHXLHQl2J3b57VwuY4VAmZdZFwGY5pJabgfWjVtzDjciYic6fnZtmAQ_CTb8_Lg2VRhwG_qw6Kv5UX5XBNONsh9_bdcBMLtl2mwgo7KXPGplbaQ0PvM32rnqzk9aDuB8WkJEb5Ls", + "dq": "X7WQaev33blWJVHCO3BBZqaJUDU5KVP7E7B-z8575oxcJzyhYqN3-Dg3EO6-s_VY2LPcBx3nUDN6CNh-h4GCX_3fQIaN61Zu-IeEuyxhAYoaqzMuiSiU-fYpGf1BMXyHNcPmF7qD3lvNZUS0qyzgCyzhOVWn5A83dLbmGpwv-kE", + "qi": "pD0kXsVUjZWnDoExmpB2QnQcSP_op-OPebrLqHVzsXBZfpkf4Do48yrnL0BjI825008dDDq3fHXxWR42Vc27zHDvkaqg9ZJpCQIOpY2jKT1jYZ-HYqQeqvXCDSHM11hfkce0OaBGhcWCKaOX3-wB8sDmD-8K3DpCTuplXCGBeWU", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "1ZHTLTyLbQs", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + }, + "encryption": {} + }, + "token": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "wrLgRGiRzLQ", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "d": "iI67yDEBeSXXpvqvQgVtHtTUf5rj2DaRVmiFqZIy6eN5dQdoNVq4LNwua3mIjZR5di1se7Vpwqe_E_6mt94IWnXwTiDDze_Y00glOQnJ9BHr53Enl5x6Rtjf555wFmRJ1-Gt3tgMfnpxWiHhwlQ6AMGjDeht9PB4lOCeXPjPUUvbkKKKBWBtVw-8e9hPZdJFjmMU_bmYL9i-gXMf6xWn4JLkrO-lVDvAqG7jlHdFN49HFBxFuxw-T4DY0GTd8OfnOBSWGaleADncTaUKL6dvXwgNtnes_PPKUfJ6BTgYpmM_4HhWMuuosarxhJAwkGoWu7LRm4W_jy5QUDFIVqTj4Q", + "p": "6MBN0ZdNba70Y3lEijgyYDE2oFtLFs3b9HtmLpr4_vQ-b0o4iasQO5bYmVW54rDvP_rCyBDs7uZUvoqeYD-xRYiPDErS5AzoeVNDoFS29fC2mNVPSqNBFOcRnqSMStuvAQwYR0zkYuCz1paAbLTZuiEmamNKx9Sxt4-FrEq6uqc", + "q": "xyHr9MFcb5VYir3d2_yRs0glIk_LNgT5uqv6R2I49iD-Z-w6EBen7M1ttkqXWA3J_kIufM75MwDjTpOFjO1Q7GVCVV5T4W9vs34Ko3u4jPJziECeIFV1ZDfyHk813eGhaGh9R_oqHe47vE2wBeRPzpIWj3ZG8yOrSTbn7eOEzG8", + "dp": "4zfRAHqPuTMiG_YoBjOEYknJBVT6giGnyA2rnHXn_KWeSfEQLr2UFEhX3aFF3dtTRYddHgj_9N1g_769jELBoZsF4z8skDtVvBOgImZxUrmS2LLtPHURtQE7Pz9uQioit4gCL6EOGMU6a5Pzfaw0HbP9F8ElIN4wPH3dRmyRzGM", + "dq": "iqVFog4XC-HR2hfEJuy9jTQIFtGzzRK9xYkEIztyKXxjZXwGGTo_QxLs9mUM5tQC9bKip2d7_lT57rWr4KlDFLST8NhSUr3B6hkx0w3LOud8JTvIXP7jUznYq92-xZPZS9akk77MIDbFBKCalB-YqVzxtEVHtPX6xmkiJnGo_qU", + "qi": "ZqcNWxzQ7lI4JxsQQhKTFAghR6J7QJMaqiiTrUiaWOSlB33kRKEdv3s1LAfMNdbGr3zl-Buhj5LOX-tPvWSV4ua9GumiHOr90Nm_WiTAJT2gbtXKToaJHSk_BeKN_8feak0Mvzwxphv8xz6C96NbXwDIDTV5YQweRFvQY5Mpmho", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "AVS5efNiEEM", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS384": { + "privateJwk": { + "kid": "1IGzLGffBQI", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "d": "qLX9ra72QELyDjg-k-Ql8ILfTFZjsn9X7QxJZLJn-e0ytgM0X-2blYj7nBC4mpTRlolJjtADVIBNCd5ryWJw8iAQwyhXz0mmMhWtQ4qml5mhI9B1RNoOOPFdcgQQEACcZ2bk-MP_HuoLm8Ju6XMsUvv0FfcXB9xtJkicEMdkGEe3uchr384r5t38ffaC-8ZA9enSoHBZwRaxFlt3i1TAGFwwQNeIsssrXJrUXi-YlZqmXaRf2Gl0fboXboFLXaWTN5RfD1iQ1zUBg55XswpkJhyR6D81XZLrTK-jOEbrhrclj5jujtk5TeqYrIZtMBNUwgRGzFkczkcNCWilFqX0aQ", + "p": "1RzqSRl2tZQrvDYVJIkufxtI-GvXVjIZYM2TUkCinAoHKN7QlwwL0QAXamr144v9JCGbMEIjcFo16Rj1Py77jLjE15ybdZpHqz3Gy_htjp0ySHJMI-T5Bxm5JxuPQLYj3k9Bhik-HcsQxJHKPXUZqpDDh-ivySd4UuGBpKOZXSc", + "q": "zc_wXz6sqrSHQPH6Yrr6oVJPmvwzBFv05g4NvWwoATZavuGo2-BdkqZVVaTPwBEB-BBgWz_VBhn48sV0gqN6mZOI9897HraPIwoNX1eWvfqPliMmbj9bHB99ZZtPqLcA6JXt3pISdE8mfEUHm65tUdvZ7l9wlU_RcHXdOS_javs", + "dp": "K9w3m7PR6q0EE0hOMabKGv7Slc4cE3FcJ8AngdYroVGvB4pUA8JG7EzIhO5ejOZSwwznk5cJFCZ80eyBDO_udZfRa06f8CRAe83LDE-kvKU9pAtiAEEvv3Zb1OCnKvpRh39oTORQFHGmkc4vgVaIYcJJe7837n5hFS20MN46wiE", + "dq": "mC1qZGJpNYdqgqDpLFtouiOsbMKRzmVX_Uri6e6w3cSc8IrWWk3ZoneOnVbRrghlVlB1jsLx9iL6KjfJ4FaUbj3ihqlJNfpyd8wU-yw-b5Z22OKApf_-lBrMk3Z1PiCicVd6nJmRP6LOqBA6gehFOMPArjqvehecmvTrcD9yfkU", + "qi": "BAG5sXbnpXWa0kUNCFgsX6YREYvSkrdeCLnpUHSw0ydU9xLswRBiQaYjoTWNHG1IfiSU-ascFqW-xZGlTEi8HDKamxZqYDyxvUMpYvSOleeMEK7Ieq580FQlzNHQ3supNMr6WK0cHsxs0dw3MBFkI4k7QknB5-mOLNvPD-F57Dc", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "ZVFUPkFyy18", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS512": { + "privateJwk": { + "kid": "zVgFtyyWWik", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "d": "taDca0jd5D7AfSYS5ea7vqZgvDPhEaBGfBMBxhXE1XRXkwSfbcQ8XbTjlWgvTOZcovxPInELJUFUv8SqEQqi-4YnM_M7LcwEFiUSjXGfOWYelgFYmh80YPMlZ3ZEVlaeDPzwy9DPH3Wc3RKrM0CV9cQiOMcy2hmZneCztEvFbohMI8bXFYeZRA-i7qJH9N3Cj_9iqGlKqnSEBl59IJX6FacX8EVi6FwCXWpJI5b6afab0dHBeZBjN-ZqRtR_kf78gaTSUKySJNrCoXpAun2HvYFXJYrt0byWho9wKt5x35SF3jcJ-DwEzjlCP9kZfw8XVPORh4tXKlbu_IrH0Ia-QQ", + "p": "_jIqpz116Ae6tqpH_HN5eT-ywJOHN_RJWbETsguBEWXjxJFdsP_M_34Rl1_T2Cz97iqde40IgkiCw2naUupwDdzY3DrmH0l8Z5nM6hyteRS14Y3z3GhX9Z_3BdsLSd76gpQdbN2C8QlG8OAHW0xT-6vYwo2sYHhEdBdmnBGIs4s", + "q": "yBdQ6sU6Px5M4sL3KR9cBTxOXJit-9Y8wdHPaSbmAZY-zVXTBWR0geLG_Dkx_c3NncSrSwWUlSVjLg-MG0EWr1W_dEjBWvAFjvCRqNoaZNFkOU_j9LG6zVyR6XPihENglaYZF3pKVDOjSqT2j0OIztcenHxd3sTE0BVBvEoedZU", + "dp": "3Cwdr7_XaYOQYPl64pouhCv9Kzpda8TG584t7hBy2dvz_eWfTlkyebX7jK7u8hZ-V5VH1KUi0p31zUbZWOpA5nD80Tye6EihXabky37NbsvWgiiPKcCjN1g4ATVqQLDHMOUT26C98wMDFE4ncRfawmlllZZa0TA6soc2VEYHruM", + "dq": "VIKEiqQCleYWUzBFc_jqxMtTzYgu887omnQjRiZHvyPWIqO9HOnwy2sc4CrIEop57cjDEEyrFNNVsH6gjmJPUn7E_jg8ckwuDNFOtCJqQ2qtCgfUH-VxIIuYlSF86qAKiyo8Ls5X1nh4324NNTUw8yuooi9k9lHlTn2r5froIoE", + "qi": "1YMuA45Yn4yHMa_B4xbRdnXdKehWJlSiksNfTbNINUqvLwOQDhCqVaPoamde4tS2nzT-ZQTxrp5jQqFGjgjTm0-p2EIFdzjs0NtLMDeEuMiHaxp7Ov1LpjdffTn_WknFgQtkjgygg2e5XQrEWDSzqNeV06blIbnegk1YnE6c8Lg", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "mROehy7CZO4", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + }, + "encryption": {} + }, + "userinfo": { + "encryption": {} + }, + "register": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "wySK0UGZma8", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "d": "pIK2-QeajDDD5wWTn8AGqhs5JOgTv4lDQL6t1i_8HqFxZNloba8DWrOeJS9_yOP9maCkdQAoS83TzWFOcf7fOFWEAAYNen86ifyCbIA8T63W0t9l1FnuBsMoI9dVUD5nbQKWVGc9Vflo4W65cTineM3ur2TA7TcTrZALHGpQ3hU9hSLPzPmazeeNKSEwy-euD3Cjm85FLdlNHrk6Leb65zbOs6fumxwUVaBq-KmyK7EerUPeAUh0K4Xy0BFt1L1x9XI4unZDG4HfR177eDS_vvL_N20KzFWZvbWJeuiwGZn2NwIeaA0kIcVHpd3gUrEy9DaV4tsrfhsUZb6apylSgQ", + "p": "_9tQNveciKNBxgX9GepZ3G5VLMQhjkvInIlA-seE2moudpsPnnZqk2ZEc0Zl7XTeoTv1fBczUZx06H4hj0gdAhkHPLUJz0YtasXyRSX53aDICacj4rJYw78a-eSJ3tBKkbDV0Q24MkDY3p3MlVAAycxwLS0wHPc7GPQwPa7K39c", + "q": "xbR0fb0vrARDTZB51sU15L9tzSvPNwkt1O07lZolgoFdDgX_0ADgqv0iHgSlBQR9hoKHTqeEAjbkxRHBmv2KIhH_cLcESMU4JkTs-j1kz5diprfuutWWvs57XjCvewbbp59l3lZFc54WeXjzBWTSxvaXTlwBlCwJHAJiF1Dw83E", + "dp": "SdcfpV183apQNzhPPYV2_bkR9-N607hnY1XxXO7sFqUCV9SUg2UliPjA1IwCqq9J-Tp2tKN1eh4vV1HfmZx0UsCqaAjPlfRo8yHBs9cr75yRXsfQAYL7PzMONASTDa0LeFSSwMy21joE3OqpuoXmVFceIMuj0RhBBAilS4gAoO0", + "dq": "Zd1XlB2w_Vlo8AL7s9wCq6yyP19OMdYp5iahZ7B3mSlcL8iJiLubBp7MQFk2SUKKBo8kdjM7ggSUlLFUZq4xyOIrEgFKVNBA4P7sdvbBBXDDpJDqkRtRw1gSGnLNR38-F7y6OPeMa0jN3aKi3GmZbGhLh1VCfvy9aNAViFvs-hE", + "qi": "xy8cIuP9Y_vwX7mOYftqv_NofI37EEBxPdqX-CeEIigflsbmaWSVADql6t-XODgK7PcbepRpxcx4AuRPBGFULvPNgEGy5YtdSSF8RwNt3GhK_d5Hh71-hs0WQ_dZ5yFMXJDTg2RpcsZwn65mN1gcc7a7qYZwciYsa1Ynmj36xmw", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "it1Z6EDEV5g", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + } + }, + "jwkSet": "{\"keys\":[{\"kid\":\"ohtdSKLCdYs\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"gI5JLLhVFG8\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"1ZHTLTyLbQs\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"AVS5efNiEEM\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"ZVFUPkFyy18\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"mROehy7CZO4\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"it1Z6EDEV5g\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true}]}" + } +} \ No newline at end of file diff --git a/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json new file mode 100644 index 000000000..59a68b506 --- /dev/null +++ b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:7777","configuration":{"issuer":"https://localhost:7777","authorization_endpoint":"https://localhost:7777/authorize","token_endpoint":"https://localhost:7777/token","userinfo_endpoint":"https://localhost:7777/userinfo","jwks_uri":"https://localhost:7777/jwks","registration_endpoint":"https://localhost:7777/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7777/session","end_session_endpoint":"https://localhost:7777/logout"},"jwks":{"keys":[{"kid":"ohtdSKLCdYs","kty":"RSA","alg":"RS256","n":"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"gI5JLLhVFG8","kty":"RSA","alg":"RS384","n":"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1ZHTLTyLbQs","kty":"RSA","alg":"RS512","n":"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"AVS5efNiEEM","kty":"RSA","alg":"RS256","n":"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"ZVFUPkFyy18","kty":"RSA","alg":"RS384","n":"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"mROehy7CZO4","kty":"RSA","alg":"RS512","n":"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"it1Z6EDEV5g","kty":"RSA","alg":"RS256","n":"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"7f1be9aa47bb1372b3bc75e91da352b4","client_secret":"c48b13e77bd58f8d7208f34cde22e898","redirect_uris":["https://localhost:7777/api/oidc/rp/https%3A%2F%2Flocalhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7777/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJhdWQiOiI3ZjFiZTlhYTQ3YmIxMzcyYjNiYzc1ZTkxZGEzNTJiNCJ9.DNCfFeM-NyvWuZHQNJlVl8gFJaRh0vOZgoUX-88sGeFR0k9KS9poySBX8hNuZ3Lrnx-_A98dH1HbVijXHSC8pn4y1Lzmh-cnM-p8u5NWGxNuZt1uLHj8hdNJW7iY4cIFvCfKq3-eblDVbyTDfIJBGPq5x0kVZ2GC1M6Qo4mufNGiHncZ_QiZDW4l9VRM6mzZ0exoiHU00YwIUaa9rGepOefPuoEqOCE7RIxUrdc3Mwa_qgyDbJj3XO58r9JHMQYP9mcweTvLV9mth-B-Azo0kp4pC4TZSEb-5VPRnDgQME-boxDJIbsNP4LfgNSWqHhp5ZLuz2AzJJVsZH8-qbGPkA","registration_client_uri":"https://localhost:7777/register/7f1be9aa47bb1372b3bc75e91da352b4","client_id_issued_at":1491941281,"client_secret_expires_at":0}} \ No newline at end of file diff --git a/test/resources/accounts-acl/localhost/index.html b/test/resources/accounts-acl/localhost/index.html new file mode 100644 index 000000000..6101fdcb7 --- /dev/null +++ b/test/resources/accounts-acl/localhost/index.html @@ -0,0 +1,35 @@ + + + + + + Welcome to Solid + + + +
+

Welcome to Solid

+
+
+
+
+

+ If you have not already done so, please create an account. +

+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + diff --git a/test/resources/accounts-acl/localhost/index.html.acl b/test/resources/accounts-acl/localhost/index.html.acl new file mode 100644 index 000000000..de9032975 --- /dev/null +++ b/test/resources/accounts-acl/localhost/index.html.acl @@ -0,0 +1,11 @@ +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo <./index.html>; + + acl:mode acl:Read. diff --git a/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl b/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl new file mode 100644 index 000000000..5296a5255 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl.acl b/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl.acl new file mode 100644 index 000000000..27f8beee7 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/append-acl/abc.ttl.acl @@ -0,0 +1,8 @@ +<#Owner> a ; + <./abc.ttl>; + ; + , , . +<#AppendOnly> a ; + <./abc.ttl>; + ; + . diff --git a/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl b/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl new file mode 100644 index 000000000..07eff8ea5 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl.acl b/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl.acl new file mode 100644 index 000000000..f4d4a027e --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/append-acl/abc2.ttl.acl @@ -0,0 +1,8 @@ +<#Owner> a ; + <./abc2.ttl>; + ; + , , . +<#Restricted> a ; + <./abc2.ttl>; + ; + , . diff --git a/test/resources/accounts-acl/tim.localhost/append-inherited/.acl b/test/resources/accounts-acl/tim.localhost/append-inherited/.acl new file mode 100644 index 000000000..7ef040005 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/append-inherited/.acl @@ -0,0 +1,13 @@ +@prefix acl: . + +<#authorization1> + a acl:Authorization; + + acl:agent + ; + acl:accessTo <./>; + acl:mode + acl:Read, acl:Write, acl:Control; + + acl:defaultForNew <./>. + diff --git a/test/resources/accounts-acl/tim.localhost/empty-acl/.acl b/test/resources/accounts-acl/tim.localhost/empty-acl/.acl new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/accounts-acl/tim.localhost/fake-account/.acl b/test/resources/accounts-acl/tim.localhost/fake-account/.acl new file mode 100644 index 000000000..f49950774 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/fake-account/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/accounts-acl/tim.localhost/fake-account/hello.html b/test/resources/accounts-acl/tim.localhost/fake-account/hello.html new file mode 100644 index 000000000..7fd820ca9 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/fake-account/hello.html @@ -0,0 +1,9 @@ + + + + Hello + + +Hello + + \ No newline at end of file diff --git a/test/resources/accounts-acl/tim.localhost/no-acl/test-file.html b/test/resources/accounts-acl/tim.localhost/no-acl/test-file.html new file mode 100644 index 000000000..16b832e3f --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/no-acl/test-file.html @@ -0,0 +1 @@ +test-file.html \ No newline at end of file diff --git a/test/resources/accounts-acl/tim.localhost/origin/.acl b/test/resources/accounts-acl/tim.localhost/origin/.acl new file mode 100644 index 000000000..632b670e6 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/origin/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/accounts-acl/tim.localhost/owner-only/.acl b/test/resources/accounts-acl/tim.localhost/owner-only/.acl new file mode 100644 index 000000000..632b670e6 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/owner-only/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/accounts-acl/tim.localhost/read-acl/.acl b/test/resources/accounts-acl/tim.localhost/read-acl/.acl new file mode 100644 index 000000000..3cf47cdbb --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/read-acl/.acl @@ -0,0 +1,10 @@ +<#Owner> + a ; + <./>; + ; + , , . +<#Public> + a ; + <./>; + ; + . diff --git a/test/resources/accounts-acl/tim.localhost/write-acl/.acl b/test/resources/accounts-acl/tim.localhost/write-acl/.acl new file mode 100644 index 000000000..632b670e6 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/write-acl/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/accounts-acl/tim.localhost/write-acl/empty-acl/.acl b/test/resources/accounts-acl/tim.localhost/write-acl/empty-acl/.acl new file mode 100644 index 000000000..e69de29bb From 10dccdb40e174f88db5d801580b675cfb578ac8d Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 13 Apr 2017 10:49:26 -0400 Subject: [PATCH 035/178] Fix file browser redirect test --- test/integration/acl-oidc.js | 2 -- test/integration/http.js | 2 +- test/resources/sampleContainer2/example1.ttl | 10 ++++++++++ test/resources/sampleContainer2/example2.ttl | 7 +++++++ 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 test/resources/sampleContainer2/example1.ttl create mode 100644 test/resources/sampleContainer2/example2.ttl diff --git a/test/integration/acl-oidc.js b/test/integration/acl-oidc.js index 7c942df76..65154d693 100644 --- a/test/integration/acl-oidc.js +++ b/test/integration/acl-oidc.js @@ -67,8 +67,6 @@ describe('ACL HTTP', function () { options.headers['Authorization'] = 'Bearer ' + accessToken } - // console.log('in createOptions:', options) - return options } diff --git a/test/integration/http.js b/test/integration/http.js index e473d34b0..ba4dc8098 100644 --- a/test/integration/http.js +++ b/test/integration/http.js @@ -263,7 +263,7 @@ describe('HTTP APIs', function () { .expect(200, done) }) it('should redirect to file browser if container was requested as text/html', function (done) { - server.get('/') + server.get('/sampleContainer2/') .set('Accept', 'text/html') .expect('content-type', /text\/html/) .expect(303, done) diff --git a/test/resources/sampleContainer2/example1.ttl b/test/resources/sampleContainer2/example1.ttl new file mode 100644 index 000000000..c2a488461 --- /dev/null +++ b/test/resources/sampleContainer2/example1.ttl @@ -0,0 +1,10 @@ +@prefix rdf: . +@prefix dc: . +@prefix ex: . + + + dc:title "RDF/XML Syntax Specification (Revised)" ; + ex:editor [ + ex:fullname "Dave Beckett"; + ex:homePage + ] . diff --git a/test/resources/sampleContainer2/example2.ttl b/test/resources/sampleContainer2/example2.ttl new file mode 100644 index 000000000..8259de95d --- /dev/null +++ b/test/resources/sampleContainer2/example2.ttl @@ -0,0 +1,7 @@ +@prefix : . +@prefix rdf: . +:a :b + [ rdf:first "apple"; + rdf:rest [ rdf:first "banana"; + rdf:rest rdf:nil ] + ] . From 60792fb195c75f82c1cff4fd22893afab9bb8bb8 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 13 Apr 2017 14:17:27 -0400 Subject: [PATCH 036/178] Add tests for userIdFromRequest() --- lib/handlers/allow.js | 25 +++++++++++++++++---- package.json | 3 ++- test/unit/acl-checker.js | 48 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 521d82e1b..5487d909a 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -1,4 +1,7 @@ -module.exports.allow = allow +module.exports = { + allow, + userIdFromRequest +} var ACL = require('../acl-checker') var $rdf = require('rdflib') @@ -93,15 +96,29 @@ function fetchDocument (host, ldp, baseUri) { } } -function getUserId (req, callback) { +/** + * Extracts the Web ID from the request object (for purposes of access control). + * + * @param req {IncomingRequest} + * + * @return {string|null} Web ID + */ +function userIdFromRequest (req) { let userId + let locals = req.app.locals if (req.session.userId) { userId = req.session.userId - } else if (req.claims) { - userId = req.claims.sub + } else if (locals.authMethod === 'oidc') { + userId = locals.oidc.webIdFromClaims(req.claims) } + return userId +} + +function getUserId (req, callback) { + let userId = userIdFromRequest(req) + callback(null, userId) // var onBehalfOf = req.get('On-Behalf-Of') // if (!onBehalfOf) { diff --git a/package.json b/package.json index d8e1a1920..aeed07bc5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.4.1", + "oidc-auth-manager": "^0.5.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", @@ -77,6 +77,7 @@ }, "devDependencies": { "chai": "^3.5.0", + "dirty-chai": "^1.2.2", "hippie": "^0.5.0", "mocha": "^3.2.0", "nock": "^9.0.2", diff --git a/test/unit/acl-checker.js b/test/unit/acl-checker.js index 3d1daaffb..c8711f87e 100644 --- a/test/unit/acl-checker.js +++ b/test/unit/acl-checker.js @@ -1,7 +1,15 @@ 'use strict' const proxyquire = require('proxyquire') -const assert = require('chai').assert +const chai = require('chai') +const { assert, expect } = chai +const dirtyChai = require('dirty-chai') +chai.use(dirtyChai) +const sinon = require('sinon') +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() const debug = require('../../lib/debug').ACL +const { userIdFromRequest } = require('../../lib/handlers/allow') class PermissionSetAlwaysGrant { checkAccess () { @@ -19,6 +27,44 @@ class PermissionSetAlwaysError { } } +describe('Allow handler', () => { + let req + let aliceWebId = 'https://alice.example.com/#me' + + beforeEach(() => { + req = { app: { locals: {} }, session: {} } + }) + + describe('userIdFromRequest()', () => { + it('should first look in session.userId', () => { + req.session.userId = aliceWebId + + let userId = userIdFromRequest(req) + + expect(userId).to.equal(aliceWebId) + }) + + it('should use webIdFromClaims() if applicable', () => { + req.app.locals.authMethod = 'oidc' + req.claims = {} + + let webIdFromClaims = sinon.stub().returns(aliceWebId) + req.app.locals.oidc = { webIdFromClaims } + + let userId = userIdFromRequest(req) + + expect(userId).to.equal(aliceWebId) + expect(webIdFromClaims).to.have.been.calledWith(req.claims) + }) + + it('should return falsy if all else fails', () => { + let userId = userIdFromRequest(req) + + expect(userId).to.not.be.ok() + }) + }) +}) + describe('ACLChecker unit test', () => { it('should callback with null on grant success', done => { let ACLChecker = proxyquire('../../lib/acl-checker', { From 412f488d00e6a85223b99b343cda4f9cb87941aa Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Tue, 18 Apr 2017 11:51:50 -0400 Subject: [PATCH 037/178] Add tests for TokenService --- lib/token-service.js | 22 +++++--- package.json | 6 +-- test/integration/authentication-oidc.js | 2 - test/unit/token-service.js | 72 +++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 test/unit/token-service.js diff --git a/lib/token-service.js b/lib/token-service.js index c39b5396a..aefd5dd3d 100644 --- a/lib/token-service.js +++ b/lib/token-service.js @@ -1,26 +1,32 @@ 'use strict' const moment = require('moment') -const uid = require('uid-safe').sync -const extend = require('extend') +const ulid = require('ulid') class TokenService { constructor () { this.tokens = {} } - generate (opts = {}) { - const token = uid(20) - this.tokens[token] = { + + generate (data = {}) { + const token = ulid() + + const value = { exp: moment().add(20, 'minutes') } - this.tokens[token] = extend(this.tokens[token], opts) + + this.tokens[token] = Object.assign({}, value, data) return token } + verify (token) { const now = new Date() - if (this.tokens[token] && now < this.tokens[token].exp) { - return this.tokens[token] + + let tokenValue = this.tokens[token] + + if (tokenValue && now < tokenValue.exp) { + return tokenValue } else { return false } diff --git a/package.json b/package.json index aeed07bc5..2cc2fe47d 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,13 @@ "jsonld": "^0.4.5", "li": "^1.0.1", "mime-types": "^2.1.11", - "moment": "^2.13.0", + "moment": "^2.18.1", "negotiator": "^0.6.0", "node-fetch": "^1.6.3", "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.5.0", + "oidc-auth-manager": "^0.5.4", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", @@ -69,7 +69,7 @@ "solid-permissions": "^0.5.1", "solid-ws": "^0.2.3", "string": "^3.3.0", - "uid-safe": "^2.1.1", + "ulid": "^0.1.0", "uuid": "^3.0.0", "valid-url": "^1.0.9", "vhost": "^3.0.2", diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc.js index 23688d7e0..f0dcc7d11 100644 --- a/test/integration/authentication-oidc.js +++ b/test/integration/authentication-oidc.js @@ -208,8 +208,6 @@ describe('Authentication API (OIDC)', () => { done(err) }) }) - - it('At /login, enter WebID & password -> redirect back to /foo') }) describe('Post-logout page (GET /goodbye)', () => { diff --git a/test/unit/token-service.js b/test/unit/token-service.js new file mode 100644 index 000000000..4e687c1ad --- /dev/null +++ b/test/unit/token-service.js @@ -0,0 +1,72 @@ +'use strict' + +const moment = require('moment') +const chai = require('chai') +const expect = chai.expect +const dirtyChai = require('dirty-chai') +chai.use(dirtyChai) +chai.should() + +const TokenService = require('../../lib/token-service') + +describe('TokenService', () => { + describe('constructor()', () => { + it('should init with an empty tokens store', () => { + let service = new TokenService() + + expect(service.tokens).to.exist() + }) + }) + + describe('generate()', () => { + it('should generate a new token and return a token key', () => { + let service = new TokenService() + + let token = service.generate() + let value = service.tokens[token] + + expect(token).to.exist() + expect(value).to.have.property('exp') + }) + }) + + describe('verify()', () => { + it('should return false for expired tokens', () => { + let service = new TokenService() + + let token = service.generate() + + service.tokens[token].exp = moment().subtract(40, 'minutes') + + expect(service.verify(token)).to.be.false() + }) + + it('should return false for non-existent tokens', () => { + let service = new TokenService() + + let token = 'invalid token 123' + + expect(service.verify(token)).to.be.false() + }) + + it('should return the token value if token not expired', () => { + let service = new TokenService() + + let token = service.generate() + + expect(service.verify(token)).to.be.ok() + }) + }) + + describe('remove()', () => { + it('should remove a generated token from the service', () => { + let service = new TokenService() + + let token = service.generate() + + service.remove(token) + + expect(service.tokens[token]).to.not.exist() + }) + }) +}) From 357da35bf166898b58fb8654a4bb2123cd538303 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 24 Apr 2017 17:39:31 -0400 Subject: [PATCH 038/178] Refactor TokenService and account manager, add tests --- default-templates/emails/reset-password.js | 49 ++++++ lib/account-recovery.js | 2 +- lib/create-app.js | 3 + lib/models/account-manager.js | 103 +++++++++++ lib/{ => models}/token-service.js | 0 package.json | 2 +- test/unit/account-manager.js | 191 ++++++++++++++++++++- test/{ => unit}/email-welcome.js | 8 +- test/unit/token-service.js | 2 +- 9 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 default-templates/emails/reset-password.js rename lib/{ => models}/token-service.js (100%) rename test/{ => unit}/email-welcome.js (88%) diff --git a/default-templates/emails/reset-password.js b/default-templates/emails/reset-password.js new file mode 100644 index 000000000..49b8ea6ab --- /dev/null +++ b/default-templates/emails/reset-password.js @@ -0,0 +1,49 @@ +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Reset Password email, upon user request + * + * @param data {Object} + * + * @param data.resetUrl {string} + * @param data.webId {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Account password reset', + + /** + * Text version + */ + text: `Hi, + +We received a request to reset your password for your Solid account, ${data.webId} + +To reset your password, click on the following link: + +${data.resetUrl} + +If you did not mean to reset your password, ignore this email, your password will not change.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to reset your password for your Solid account, ${data.webId}

+ +

To reset your password, click on the following link:

+ +

${data.resetUrl}

+ +

If you did not mean to reset your password, ignore this email, your password will not change.

+` + } +} + +module.exports.render = render diff --git a/lib/account-recovery.js b/lib/account-recovery.js index 6821c809b..58d034afb 100644 --- a/lib/account-recovery.js +++ b/lib/account-recovery.js @@ -1,7 +1,7 @@ module.exports = AccountRecovery const express = require('express') -const TokenService = require('./token-service') +const TokenService = require('./models/token-service') const bodyParser = require('body-parser') const path = require('path') const debug = require('debug')('solid:account-recovery') diff --git a/lib/create-app.js b/lib/create-app.js index 550b4cc6a..e7171e245 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -12,6 +12,7 @@ const SolidHost = require('./models/solid-host') const AccountManager = require('./models/account-manager') const vhost = require('vhost') const EmailService = require('./models/email-service') +const TokenService = require('./models/token-service') const AccountRecovery = require('./account-recovery') const capabilityDiscovery = require('./capability-discovery') const bodyParser = require('body-parser').urlencoded({ extended: false }) @@ -90,6 +91,7 @@ function initAppLocals (app, argv, ldp) { app.locals.appUrls = argv.apps // used for service capability discovery app.locals.host = argv.host app.locals.authMethod = argv.auth + app.locals.tokenService = new TokenService() if (argv.email && argv.email.host) { app.locals.emailService = new EmailService(argv.templates.email, argv.email) @@ -152,6 +154,7 @@ function initWebId (argv, app, ldp) { let accountManager = AccountManager.from({ authMethod: argv.auth, emailService: app.locals.emailService, + tokenService: app.locals.tokenService, host: argv.host, accountTemplatePath: argv.templates.account, store: ldp, diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 2d5d1800b..68dbde57e 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -24,6 +24,7 @@ class AccountManager { * @param [options={}] {Object} * @param [options.authMethod] {string} Primary authentication method (e.g. 'tls') * @param [options.emailService] {EmailService} + * @param [options.tokenService] {TokenService} * @param [options.host] {SolidHost} * @param [options.multiUser=false] {boolean} (argv.idp) Is the server running * in multiUser mode (users can sign up for accounts) or single user @@ -41,6 +42,7 @@ class AccountManager { } this.host = options.host this.emailService = options.emailService + this.tokenService = options.tokenService this.authMethod = options.authMethod || defaults.auth this.multiUser = options.multiUser || false this.store = options.store @@ -200,6 +202,22 @@ class AccountManager { return webIdUri.format() } + /** + * Returns the root .acl URI for a given user account (the account recovery + * email is stored there). + * + * @param userAccount {UserAccount} + * + * @throws {Error} via accountUriFor() + * + * @return {string} Root .acl URI + */ + rootAclFor (userAccount) { + let accountUri = this.accountUriFor(userAccount.username) + + return url.resolve(accountUri, this.store.suffixAcl) + } + /** * Adds a newly generated WebID-TLS certificate to the user's profile graph. * @@ -352,6 +370,91 @@ class AccountManager { }) } + /** + * Generates an expiring one-time-use token for password reset purposes + * (the user's Web ID is saved in the token service). + * + * @param userAccount {UserAccount} + * + * @return {string} Generated token + */ + generateResetToken (userAccount) { + return this.tokenService.generate({ webId: userAccount.webId }) + } + + /** + * Returns a password reset URL (to be emailed to the user upon request) + * + * @param token {string} One-time-use expiring token, via the TokenService + * @param returnToUrl {string} + * + * @return {string} + */ + passwordResetUrl (token, returnToUrl) { + let encodedReturnTo = encodeURIComponent(returnToUrl) + + let resetUrl = url.resolve(this.host.serverUri, + `/api/password/validateReset?token=${token}` + + `&returnToUrl=${encodedReturnTo}`) + + return resetUrl + } + + /** + * Parses and returns an account recovery email stored in a user's root .acl + * + * @param userAccount {UserAccount} + * + * @return {Promise} + */ + loadAccountRecoveryEmail (userAccount) { + return Promise.resolve() + .then(() => { + let rootAclUri = this.rootAclFor(userAccount) + + return this.store.getGraph(rootAclUri) + }) + .then(rootAclGraph => { + let matches = rootAclGraph.match(null, ns.acl('agent')) + + let recoveryMailto = matches.find(agent => { + return agent.object.value.startsWith('mailto:') + }) + + if (recoveryMailto) { + recoveryMailto = recoveryMailto.object.value.replace('mailto:', '') + } + + return recoveryMailto + }) + } + + sendPasswordResetEmail (userAccount, returnToUrl) { + return Promise.resolve() + .then(() => { + if (!this.emailService) { + throw new Error('Email service is not set up') + } + + if (!userAccount.email) { + throw new Error('Account recovery email has not been provided') + } + + return this.generateResetToken(userAccount) + }) + .then(resetToken => { + let resetUrl = this.passwordResetUrl(resetToken, returnToUrl) + + let emailData = { + to: userAccount.email, + webId: userAccount.webId, + resetUrl + } + + return this.emailService.sendWithTemplate('reset-password', emailData) + }) + } + /** * Sends a Welcome email (on new user signup). * diff --git a/lib/token-service.js b/lib/models/token-service.js similarity index 100% rename from lib/token-service.js rename to lib/models/token-service.js diff --git a/package.json b/package.json index 2cc2fe47d..625548ea6 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "node-mocks-http": "^1.5.6", "nyc": "^10.1.2", "proxyquire": "^1.7.10", - "sinon": "^1.17.7", + "sinon": "^2.1.0", "sinon-chai": "^2.8.0", "standard": "^8.6.0", "supertest": "^1.2.0" diff --git a/test/unit/account-manager.js b/test/unit/account-manager.js index e7ce1b859..1f5d55a66 100644 --- a/test/unit/account-manager.js +++ b/test/unit/account-manager.js @@ -9,10 +9,12 @@ chai.use(sinonChai) chai.should() const rdf = require('rdflib') +const ns = require('solid-namespace')(rdf) const LDP = require('../../lib/ldp') const SolidHost = require('../../lib/models/solid-host') const AccountManager = require('../../lib/models/account-manager') const UserAccount = require('../../lib/models/user-account') +const TokenService = require('../../lib/models/token-service') const WebIdTlsCertificate = require('../../lib/models/webid-tls-certificate') const testAccountsDir = path.join(__dirname, '../resources/accounts') @@ -31,7 +33,8 @@ describe('AccountManager', () => { authMethod: 'tls', multiUser: true, store: {}, - emailService: {} + emailService: {}, + tokenService: {} } let mgr = AccountManager.from(config) @@ -40,6 +43,7 @@ describe('AccountManager', () => { expect(mgr.multiUser).to.equal(config.multiUser) expect(mgr.store).to.equal(config.store) expect(mgr.emailService).to.equal(config.emailService) + expect(mgr.tokenService).to.equal(config.tokenService) }) it('should error if no host param is passed in', () => { @@ -265,4 +269,189 @@ describe('AccountManager', () => { }) }) }) + + describe('rootAclFor()', () => { + it('should return the server root .acl in single user mode', () => { + let store = new LDP({ suffixAcl: '.acl', idp: false }) + let options = { host, multiUser: false, store } + let accountManager = AccountManager.from(options) + + let userAccount = UserAccount.from({ username: 'alice' }) + + let rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://example.com/.acl') + }) + + it('should return the profile root .acl in multi user mode', () => { + let store = new LDP({ suffixAcl: '.acl', idp: true }) + let options = { host, multiUser: true, store } + let accountManager = AccountManager.from(options) + + let userAccount = UserAccount.from({ username: 'alice' }) + + let rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://alice.example.com/.acl') + }) + }) + + describe('loadAccountRecoveryEmail()', () => { + it('parses and returns the agent mailto from the root acl', () => { + let userAccount = UserAccount.from({ username: 'alice' }) + + let rootAclGraph = rdf.graph() + rootAclGraph.add( + rdf.namedNode('https://alice.example.com/.acl#owner'), + ns.acl('agent'), + rdf.namedNode('mailto:alice@example.com') + ) + + let store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(rootAclGraph) + } + + let options = { host, multiUser: true, store } + let accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.equal('alice@example.com') + }) + }) + + it('should return undefined when agent mailto is missing', () => { + let userAccount = UserAccount.from({ username: 'alice' }) + + let emptyGraph = rdf.graph() + + let store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(emptyGraph) + } + + let options = { host, multiUser: true, store } + let accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.be.undefined() + }) + }) + }) + + describe('passwordResetUrl()', () => { + it('should return a token reset validation url', () => { + let tokenService = new TokenService() + let options = { host, multiUser: true, tokenService } + + let accountManager = AccountManager.from(options) + + let returnToUrl = 'https://example.com/resource' + let token = '123' + + let resetUrl = accountManager.passwordResetUrl(token, returnToUrl) + + let expectedUri = 'https://example.com/api/password/validateReset?' + + 'token=123&returnToUrl=' + encodeURIComponent(returnToUrl) + + expect(resetUrl).to.equal(expectedUri) + }) + }) + + describe('generateResetToken()', () => { + it('should generate and store an expiring reset token', () => { + let tokenService = new TokenService() + let options = { host, tokenService } + + let accountManager = AccountManager.from(options) + + let aliceWebId = 'https://alice.example.com/#me' + let userAccount = { + webId: aliceWebId + } + + let token = accountManager.generateResetToken(userAccount) + + let tokenValue = accountManager.tokenService.verify(token) + + expect(tokenValue.webId).to.equal(aliceWebId) + expect(tokenValue).to.have.property('exp') + }) + }) + + describe('sendPasswordResetEmail()', () => { + it('should compose and send a password reset email', () => { + let resetToken = '1234' + let tokenService = { + generate: sinon.stub().returns(resetToken) + } + + let emailService = { + sendWithTemplate: sinon.stub().resolves() + } + + let aliceWebId = 'https://alice.example.com/#me' + let userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + let returnToUrl = 'https://example.com/resource' + + let options = { host, tokenService, emailService } + let accountManager = AccountManager.from(options) + + accountManager.passwordResetUrl = sinon.stub().returns('reset url') + + let expectedEmailData = { + to: 'alice@example.com', + webId: aliceWebId, + resetUrl: 'reset url' + } + + return accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .then(() => { + expect(accountManager.passwordResetUrl) + .to.have.been.calledWith(resetToken, returnToUrl) + expect(emailService.sendWithTemplate) + .to.have.been.calledWith('reset-password', expectedEmailData) + }) + }) + + it('should reject if no email service is set up', done => { + let aliceWebId = 'https://alice.example.com/#me' + let userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + let returnToUrl = 'https://example.com/resource' + let options = { host } + let accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Email service is not set up') + done() + }) + }) + + it('should reject if no user email is provided', done => { + let aliceWebId = 'https://alice.example.com/#me' + let userAccount = { + webId: aliceWebId + } + let returnToUrl = 'https://example.com/resource' + let emailService = {} + let options = { host, emailService } + + let accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Account recovery email has not been provided') + done() + }) + }) + }) }) diff --git a/test/email-welcome.js b/test/unit/email-welcome.js similarity index 88% rename from test/email-welcome.js rename to test/unit/email-welcome.js index 33a318c07..189a3285a 100644 --- a/test/email-welcome.js +++ b/test/unit/email-welcome.js @@ -8,11 +8,11 @@ const sinonChai = require('sinon-chai') chai.use(sinonChai) chai.should() -const SolidHost = require('../lib/models/solid-host') -const AccountManager = require('../lib/models/account-manager') -const EmailService = require('../lib/models/email-service') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') +const EmailService = require('../../lib/models/email-service') -const templatePath = path.join(__dirname, '../default-templates/emails') +const templatePath = path.join(__dirname, '../../default-templates/emails') var host, accountManager, emailService diff --git a/test/unit/token-service.js b/test/unit/token-service.js index 4e687c1ad..aa528dd41 100644 --- a/test/unit/token-service.js +++ b/test/unit/token-service.js @@ -7,7 +7,7 @@ const dirtyChai = require('dirty-chai') chai.use(dirtyChai) chai.should() -const TokenService = require('../../lib/token-service') +const TokenService = require('../../lib/models/token-service') describe('TokenService', () => { describe('constructor()', () => { From 3b915ff4d272323ef5f37ea6ad7213dd0de4019f Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Tue, 25 Apr 2017 15:12:14 -0400 Subject: [PATCH 039/178] Implement password reset request and tests --- default-templates/emails/reset-password.js | 2 +- default-views/account/register.hbs | 16 +- default-views/auth/login.hbs | 26 ++- default-views/auth/reset-link-sent.hbs | 17 ++ default-views/auth/reset-password.hbs | 58 ++++++ lib/api/authn/webid-oidc.js | 5 + lib/models/account-manager.js | 8 +- lib/models/email-service.js | 2 +- lib/requests/auth-request.js | 13 ++ lib/requests/create-account-request.js | 15 +- lib/requests/login-request.js | 2 - lib/requests/password-reset-email-request.js | 145 ++++++++++++++ test/unit/account-manager.js | 4 +- test/unit/password-reset-request.js | 192 +++++++++++++++++++ 14 files changed, 475 insertions(+), 30 deletions(-) create mode 100644 default-views/auth/reset-link-sent.hbs create mode 100644 default-views/auth/reset-password.hbs create mode 100644 lib/requests/auth-request.js create mode 100644 lib/requests/password-reset-email-request.js create mode 100644 test/unit/password-reset-request.js diff --git a/default-templates/emails/reset-password.js b/default-templates/emails/reset-password.js index 49b8ea6ab..fb18972cc 100644 --- a/default-templates/emails/reset-password.js +++ b/default-templates/emails/reset-password.js @@ -39,7 +39,7 @@ If you did not mean to reset your password, ignore this email, your password wil

To reset your password, click on the following link:

-

${data.resetUrl}

+

If you did not mean to reset your password, ignore this email, your password will not change.

` diff --git a/default-views/account/register.hbs b/default-views/account/register.hbs index c7c6971ed..5042b3828 100644 --- a/default-views/account/register.hbs +++ b/default-views/account/register.hbs @@ -45,12 +45,20 @@ - - -
Already have an account? - Log In +
+
+
+ +
+ +
+
Already have an account? + Log In +
+
+
diff --git a/default-views/auth/login.hbs b/default-views/auth/login.hbs index 9c167bd63..6319ec917 100644 --- a/default-views/auth/login.hbs +++ b/default-views/auth/login.hbs @@ -32,6 +32,27 @@ + + +
+
+
+ +
+ +
+
Don't have an account? + Register +
+
+ +
+
Forgot password? + Reset password +
+
+
+ @@ -40,11 +61,6 @@
- - -
Don't have an account? - Register -
diff --git a/default-views/auth/reset-link-sent.hbs b/default-views/auth/reset-link-sent.hbs new file mode 100644 index 000000000..059727515 --- /dev/null +++ b/default-views/auth/reset-link-sent.hbs @@ -0,0 +1,17 @@ + + + + + + Reset Link Sent + + + +
+

Reset Link Sent

+
+
+ A Reset Password link has been sent to your email. +
+ + diff --git a/default-views/auth/reset-password.hbs b/default-views/auth/reset-password.hbs new file mode 100644 index 000000000..89aff04c5 --- /dev/null +++ b/default-views/auth/reset-password.hbs @@ -0,0 +1,58 @@ + + + + + + Reset Password + + + +
+

Reset Password

+
+
+
+
+ {{#if error}} +
+
+

{{error}}

+
+
+ {{/if}} +
+
+ {{#if multiUser}} +

Please enter your account name. A password reset link will be + emailed to the address you provided during account registration.

+ + + + {{else}} +

A password reset link will be + emailed to the address you provided during account registration.

+ {{/if}} +
+
+
+ +
+
+
+ +
+ +
+
Don't have an account? + Register +
+
+
+ + +
+
+
+ + diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 4257a0413..5ac75b758 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -8,6 +8,8 @@ const bodyParser = require('body-parser').urlencoded({ extended: false }) const { LoginByPasswordRequest } = require('../../requests/login-request') +const PasswordResetEmailRequest = require('../../requests/password-reset-email-request') + const { AuthCallbackRequest, LogoutRequest, @@ -33,6 +35,9 @@ function middleware (oidc) { router.get(['/login', '/signin'], LoginByPasswordRequest.get) router.post(['/login', '/signin'], bodyParser, LoginByPasswordRequest.post) + router.get('/account/password/reset', PasswordResetEmailRequest.get) + router.post('/account/password/reset', bodyParser, PasswordResetEmailRequest.post) + router.get('/logout', LogoutRequest.handle) router.get('/goodbye', (req, res) => { res.render('auth/goodbye') }) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 68dbde57e..f16794656 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -391,11 +391,9 @@ class AccountManager { * @return {string} */ passwordResetUrl (token, returnToUrl) { - let encodedReturnTo = encodeURIComponent(returnToUrl) - let resetUrl = url.resolve(this.host.serverUri, - `/api/password/validateReset?token=${token}` + - `&returnToUrl=${encodedReturnTo}`) + `/account/password/change?token=${token}` + + `&returnToUrl=${returnToUrl}`) return resetUrl } @@ -445,6 +443,8 @@ class AccountManager { .then(resetToken => { let resetUrl = this.passwordResetUrl(resetToken, returnToUrl) + debug('Reset URL:', resetUrl) + let emailData = { to: userAccount.email, webId: userAccount.webId, diff --git a/lib/models/email-service.js b/lib/models/email-service.js index 1d43cbfad..8eb9865d3 100644 --- a/lib/models/email-service.js +++ b/lib/models/email-service.js @@ -119,7 +119,7 @@ class EmailService { emailFromTemplate (templateName, data) { let template = this.readTemplate(templateName) - return template.render(data) + return Object.assign({}, template.render(data), data) } /** diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js new file mode 100644 index 000000000..e59b8780b --- /dev/null +++ b/lib/requests/auth-request.js @@ -0,0 +1,13 @@ +'use strict' + +class AuthRequest { + static parseParameter (req, parameter) { + let query = req.query || {} + let body = req.body || {} + let params = req.params || {} + + return query[parameter] || body[parameter] || params[parameter] || null + } +} + +module.exports = AuthRequest diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index 2ab4b5767..776d1c9a2 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -1,5 +1,6 @@ 'use strict' +const AuthRequest = require('./auth-request') const WebIdTlsCertificate = require('../models/webid-tls-certificate') const debug = require('../debug').accounts @@ -16,7 +17,7 @@ const debug = require('../debug').accounts * * @class CreateAccountRequest */ -class CreateAccountRequest { +class CreateAccountRequest extends AuthRequest { /** * @param [options={}] {Object} * @param [options.accountManager] {AccountManager} @@ -27,6 +28,7 @@ class CreateAccountRequest { * this url on successful account creation */ constructor (options) { + super() this.accountManager = options.accountManager this.userAccount = options.userAccount this.session = options.session @@ -55,7 +57,7 @@ class CreateAccountRequest { let locals = req.app.locals let accountManager = locals.accountManager let authMethod = locals.authMethod - let returnToUrl = CreateAccountRequest.parseReturnUrl(req) + let returnToUrl = this.parseParameter(req, 'returnToUrl') let userAccount = accountManager.userAccountFrom(req.body) let options = { @@ -90,15 +92,6 @@ class CreateAccountRequest { response.render('account/register', params) } - static parseReturnUrl (req) { - let body = req.body || {} - if (body.returnToUrl) { return req.body.returnToUrl } - - if (req.query && req.query.returnToUrl) { return req.query.returnToUrl } - - return null - } - static post (req, res) { let request let returnToUrl = req.body.returnToUrl diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index 1230ec339..bfa1daf34 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -50,8 +50,6 @@ class LoginByPasswordRequest { * * @param req {IncomingRequest} * @param res {ServerResponse} - * - * @return {Promise} */ static get (req, res) { const request = LoginByPasswordRequest.fromParams(req, res) diff --git a/lib/requests/password-reset-email-request.js b/lib/requests/password-reset-email-request.js new file mode 100644 index 000000000..9e82c5f61 --- /dev/null +++ b/lib/requests/password-reset-email-request.js @@ -0,0 +1,145 @@ +'use strict' + +const AuthRequest = require('./auth-request') +const debug = require('./../debug').accounts + +class PasswordResetEmailRequest extends AuthRequest { + /** + * @constructor + * @param options {Object} + * @param options.accountManager {AccountManager} + * @param options.response {ServerResponse} express response object + * @param [options.returnToUrl] {string} + * @param [options.username] {string} Username / account name (e.g. 'alice') + */ + constructor (options) { + super() + this.accountManager = options.accountManager + this.response = options.response + this.returnToUrl = options.returnToUrl + this.username = options.username + } + + static fromParams (req, res) { + let locals = req.app.locals + let accountManager = locals.accountManager + + let returnToUrl = this.parseParameter(req, 'returnToUrl') + let username = this.parseParameter(req, 'username') + + let options = { + accountManager, + returnToUrl, + username, + response: res + } + + return new PasswordResetEmailRequest(options) + } + + /** + * Handles a Reset Password GET request on behalf of a middleware handler. + * Usage: + * + * ``` + * app.get('/password/reset', PasswordResetEmailRequest.get) + * ``` + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + */ + static get (req, res) { + const request = PasswordResetEmailRequest.fromParams(req, res) + + request.renderForm() + } + + static post (req, res) { + const request = PasswordResetEmailRequest.fromParams(req, res) + + debug(`User '${request.username}' requested to be sent a password reset email`) + + return PasswordResetEmailRequest.handlePost(request) + } + + static handlePost (request) { + return Promise.resolve() + .then(() => request.validate()) + .then(() => request.loadUser()) + .then(userAccount => request.sendResetLink(userAccount)) + .then(() => request.renderSuccess()) + .catch(error => request.error(error)) + } + + validate () { + if (this.accountManager.multiUser && !this.username) { + throw new Error('Username required') + } + } + + /** + * Returns a user account instance for the submitted username. + * + * @throws {Error} Rejects if user account does not exist for the username + * + * @returns {Promise} + */ + loadUser () { + let username = this.username + + return this.accountManager.accountExists(username) + .then(exists => { + if (!exists) { + throw new Error('Account not found for that username') + } + + let userData = { username } + + return this.accountManager.userAccountFrom(userData) + }) + } + + sendResetLink (userAccount) { + let accountManager = this.accountManager + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + userAccount.email = recoveryEmail + + debug('Sending recovery email to:', recoveryEmail) + + return accountManager + .sendPasswordResetEmail(userAccount, this.returnToUrl) + }) + } + + error (error) { + let res = this.response + + debug(error) + + let params = { + error: error.message, + returnToUrl: this.returnToUrl + } + + res.status(error.statusCode || 400) + + res.render('auth/reset-password', params) + } + + renderForm () { + let params = { + returnToUrl: this.returnToUrl, + multiUser: this.accountManager.multiUser + } + + this.response.render('auth/reset-password', params) + } + + renderSuccess () { + this.response.render('auth/reset-link-sent') + } +} + +module.exports = PasswordResetEmailRequest diff --git a/test/unit/account-manager.js b/test/unit/account-manager.js index 1f5d55a66..0d9990e09 100644 --- a/test/unit/account-manager.js +++ b/test/unit/account-manager.js @@ -353,8 +353,8 @@ describe('AccountManager', () => { let resetUrl = accountManager.passwordResetUrl(token, returnToUrl) - let expectedUri = 'https://example.com/api/password/validateReset?' + - 'token=123&returnToUrl=' + encodeURIComponent(returnToUrl) + let expectedUri = 'https://example.com/account/password/change?' + + 'token=123&returnToUrl=' + returnToUrl expect(resetUrl).to.equal(expectedUri) }) diff --git a/test/unit/password-reset-request.js b/test/unit/password-reset-request.js new file mode 100644 index 000000000..74fed9035 --- /dev/null +++ b/test/unit/password-reset-request.js @@ -0,0 +1,192 @@ +'use strict' + +const chai = require('chai') +const sinon = require('sinon') +const expect = chai.expect +const dirtyChai = require('dirty-chai') +chai.use(dirtyChai) +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() + +const HttpMocks = require('node-mocks-http') + +const PasswordResetEmailRequest = require('../../lib/requests/password-reset-email-request') +const AccountManager = require('../../lib/models/account-manager') +const SolidHost = require('../../lib/models/solid-host') + +describe('PasswordResetEmailRequest', () => { + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + let res = HttpMocks.createResponse() + + let options = { + returnToUrl: 'https://example.com/resource', + response: res, + username: 'alice' + } + + let request = new PasswordResetEmailRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.username).to.equal(options.username) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + let returnToUrl = 'https://example.com/resource' + let username = 'alice' + let accountManager = {} + + let req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + let res = HttpMocks.createResponse() + + let request = PasswordResetEmailRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.username).to.equal(username) + expect(request.response).to.equal(res) + }) + }) + + describe('get()', () => { + it('should create an instance and render a reset password form', () => { + let returnToUrl = 'https://example.com/resource' + let username = 'alice' + let accountManager = { multiUser: true } + + let req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + let res = HttpMocks.createResponse() + res.render = sinon.stub() + + PasswordResetEmailRequest.get(req, res) + + expect(res.render).to.have.been.calledWith('auth/reset-password', + { returnToUrl, multiUser: true }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordResetEmailRequest, 'handlePost') + + let returnToUrl = 'https://example.com/resource' + let username = 'alice' + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let store = { + suffixAcl: '.acl' + } + let accountManager = AccountManager.from({ host, multiUser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + let req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + let res = HttpMocks.createResponse() + + PasswordResetEmailRequest.post(req, res) + .then(() => { + expect(PasswordResetEmailRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('validate()', () => { + it('should throw an error if username is missing in multiUser mode', () => { + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let accountManager = AccountManager.from({ host, multiUser: true }) + + let request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.throw(/Username required/) + }) + + it('should not throw an error if username is missing in single user mode', () => { + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let accountManager = AccountManager.from({ host, multiUser: false }) + + let request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.not.throw() + }) + }) + + describe('handlePost()', () => { + it('should handle the post request', () => { + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let store = { suffixAcl: '.acl' } + let accountManager = AccountManager.from({ host, multiUser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(true) + + let returnToUrl = 'https://example.com/resource' + let username = 'alice' + let response = HttpMocks.createResponse() + response.render = sinon.stub() + + let options = { accountManager, username, returnToUrl, response } + let request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'error') + + return PasswordResetEmailRequest.handlePost(request) + .then(() => { + expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() + expect(accountManager.sendPasswordResetEmail).to.have.been.called() + expect(response.render).to.have.been.calledWith('auth/reset-link-sent') + expect(request.error).to.not.have.been.called() + }) + }) + }) + + describe('loadUser()', () => { + it('should return a UserAccount instance based on username', () => { + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let store = { suffixAcl: '.acl' } + let accountManager = AccountManager.from({ host, multiUser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + let username = 'alice' + + let options = { accountManager, username } + let request = new PasswordResetEmailRequest(options) + + return request.loadUser() + .then(account => { + expect(account.webId).to.equal('https://alice.example.com/profile/card#me') + }) + }) + + it('should throw an error if the user does not exist', done => { + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let store = { suffixAcl: '.acl' } + let accountManager = AccountManager.from({ host, multiUser: true, store }) + accountManager.accountExists = sinon.stub().resolves(false) + let username = 'alice' + + let options = { accountManager, username } + let request = new PasswordResetEmailRequest(options) + + request.loadUser() + .catch(error => { + expect(error.message).to.equal('Account not found for that username') + done() + }) + }) + }) +}) From 4ca105ebd544f072aa1506960a029d72248ca701 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Wed, 26 Apr 2017 15:45:44 -0400 Subject: [PATCH 040/178] Implement reset token validation and change password page --- default-views/account/register.hbs | 5 +- default-views/auth/change-password.hbs | 65 ++++++++ default-views/auth/login.hbs | 12 +- default-views/auth/password-changed.hbs | 23 +++ default-views/auth/reset-password.hbs | 3 +- lib/api/authn/webid-oidc.js | 4 + lib/models/account-manager.js | 54 ++++++- lib/requests/login-request.js | 7 +- lib/requests/password-change-request.js | 149 +++++++++++++++++++ lib/requests/password-reset-email-request.js | 3 +- package.json | 2 +- test/unit/login-by-password-request.js | 12 +- 12 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 default-views/auth/change-password.hbs create mode 100644 default-views/auth/password-changed.hbs create mode 100644 lib/requests/password-change-request.js diff --git a/default-views/account/register.hbs b/default-views/account/register.hbs index 5042b3828..769449fba 100644 --- a/default-views/account/register.hbs +++ b/default-views/account/register.hbs @@ -55,7 +55,10 @@
Already have an account? - Log In + + Log In +
diff --git a/default-views/auth/change-password.hbs b/default-views/auth/change-password.hbs new file mode 100644 index 000000000..88a0d8292 --- /dev/null +++ b/default-views/auth/change-password.hbs @@ -0,0 +1,65 @@ + + + + + + Change Password + + + +
+

Change Password

+
+
+
+ {{#if error}} +
+
+
+

{{error}}

+
+
+
+ {{/if}} + + {{#if validToken}} +
+
+
+ + + +
+
+
+ +
+
+
+ +
+
+ + + +
+ {{else}} + + {{/if}} +
+
+ + diff --git a/default-views/auth/login.hbs b/default-views/auth/login.hbs index 6319ec917..b9247fdde 100644 --- a/default-views/auth/login.hbs +++ b/default-views/auth/login.hbs @@ -37,18 +37,24 @@
- +
Don't have an account? - Register + + Register +
diff --git a/default-views/auth/password-changed.hbs b/default-views/auth/password-changed.hbs new file mode 100644 index 000000000..4522cf9ed --- /dev/null +++ b/default-views/auth/password-changed.hbs @@ -0,0 +1,23 @@ + + + + + + Password Changed + + + +
+

Password Changed

+
+
+

Your password has been changed.

+ +

+ + Log in + +

+
+ + diff --git a/default-views/auth/reset-password.hbs b/default-views/auth/reset-password.hbs index 89aff04c5..8821171ad 100644 --- a/default-views/auth/reset-password.hbs +++ b/default-views/auth/reset-password.hbs @@ -45,7 +45,8 @@
Don't have an account? - Register + Register
diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 5ac75b758..d3eebfe75 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -9,6 +9,7 @@ const bodyParser = require('body-parser').urlencoded({ extended: false }) const { LoginByPasswordRequest } = require('../../requests/login-request') const PasswordResetEmailRequest = require('../../requests/password-reset-email-request') +const PasswordChangeRequest = require('../../requests/password-change-request') const { AuthCallbackRequest, @@ -38,6 +39,9 @@ function middleware (oidc) { router.get('/account/password/reset', PasswordResetEmailRequest.get) router.post('/account/password/reset', bodyParser, PasswordResetEmailRequest.post) + router.get('/account/password/change', PasswordChangeRequest.get) + router.post('/account/password/change', bodyParser, PasswordChangeRequest.post) + router.get('/logout', LogoutRequest.handle) router.get('/goodbye', (req, res) => { res.render('auth/goodbye') }) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index f16794656..2a5ec0474 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -11,6 +11,7 @@ const AccountTemplate = require('./account-template') const debug = require('./../debug').accounts const DEFAULT_PROFILE_CONTENT_TYPE = 'text/turtle' +const DEFAULT_ADMIN_USERNAME = 'admin' /** * Manages account creation (determining whether accounts exist, creating @@ -192,7 +193,8 @@ class AccountManager { * @param [accountName] {string} * * @throws {Error} via accountUriFor() - * @return {string} + * + * @return {string|null} */ accountWebIdFor (accountName) { let accountUri = this.accountUriFor(accountName) @@ -343,12 +345,32 @@ class AccountManager { username: userData.username, email: userData.email, name: userData.name, - webId: userData.webid || this.accountWebIdFor(userData.username) + webId: userData.webid || userData.webId || + this.accountWebIdFor(userData.username) + } + + if (userConfig.webId && !userConfig.username) { + userConfig.username = this.usernameFromWebId(userConfig.webId) + } + + if (!userConfig.webId && !userConfig.username) { + throw new Error('Username or web id is required') } return UserAccount.from(userConfig) } + usernameFromWebId (webId) { + if (!this.multiUser) { + return DEFAULT_ADMIN_USERNAME + } + + let profileUrl = url.parse(webId) + let hostname = profileUrl.hostname + + return hostname.split('.')[0] + } + /** * Creates a user account storage folder (from a default account template). * @@ -382,6 +404,27 @@ class AccountManager { return this.tokenService.generate({ webId: userAccount.webId }) } + /** + * Validates that a token exists and is not expired, and returns the saved + * token contents, or throws an error if invalid. + * Does not consume / clear the token. + * + * @param token {string} + * + * @throws {Error} If missing or invalid token + * + * @return {Object} Saved token data object + */ + validateResetToken (token) { + let tokenValue = this.tokenService.verify(token) + + if (!tokenValue) { + throw new Error('Invalid or expired reset token') + } + + return tokenValue + } + /** * Returns a password reset URL (to be emailed to the user upon request) * @@ -392,8 +435,11 @@ class AccountManager { */ passwordResetUrl (token, returnToUrl) { let resetUrl = url.resolve(this.host.serverUri, - `/account/password/change?token=${token}` + - `&returnToUrl=${returnToUrl}`) + `/account/password/change?token=${token}`) + + if (returnToUrl) { + resetUrl += `&returnToUrl=${returnToUrl}` + } return resetUrl } diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index bfa1daf34..e1373db7e 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -4,7 +4,6 @@ const url = require('url') const validUrl = require('valid-url') const debug = require('./../debug').authentication -const UserAccount = require('../models/user-account') /** * Models a Login request, a POST submit from a Login form with a username and @@ -221,7 +220,7 @@ class LoginByPasswordRequest { if (validUrl.isUri(this.username)) { // A WebID URI was entered into the username field - userOptions = { webid: this.username } + userOptions = { webId: this.username } } else { // A regular username userOptions = { username: this.username } @@ -246,14 +245,14 @@ class LoginByPasswordRequest { }) .then(validUser => { if (!validUser) { - error = new Error('User found but no password found') + error = new Error('User found but no password match') error.statusCode = 400 throw error } debug('User found, password matches') - return UserAccount.from(validUser) + return this.accountManager.userAccountFrom(validUser) }) } diff --git a/lib/requests/password-change-request.js b/lib/requests/password-change-request.js new file mode 100644 index 000000000..96f1c20ec --- /dev/null +++ b/lib/requests/password-change-request.js @@ -0,0 +1,149 @@ +'use strict' + +const AuthRequest = require('./auth-request') +const debug = require('./../debug').accounts + +class PasswordChangeRequest extends AuthRequest { + /** + * @constructor + * @param options {Object} + * @param options.accountManager {AccountManager} + * @param options.userStore {UserStore} + * @param options.response {ServerResponse} express response object + * @param [options.token] {string} One-time reset password token (from email) + * @param [options.returnToUrl] {string} + * @param [options.newPassword] {string} New password to save + */ + constructor (options) { + super() + this.accountManager = options.accountManager + this.userStore = options.userStore + this.response = options.response + + this.token = options.token + this.returnToUrl = options.returnToUrl + + this.validToken = false + + this.newPassword = options.newPassword + } + + static fromParams (req, res) { + let locals = req.app.locals + let accountManager = locals.accountManager + let userStore = locals.oidc.users + + let returnToUrl = this.parseParameter(req, 'returnToUrl') + let token = this.parseParameter(req, 'token') + let oldPassword = this.parseParameter(req, 'password') + let newPassword = this.parseParameter(req, 'newPassword') + + let options = { + accountManager, + userStore, + returnToUrl, + token, + oldPassword, + newPassword, + response: res + } + + return new PasswordChangeRequest(options) + } + + /** + * Handles a Change Password GET request on behalf of a middleware handler. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + */ + static get (req, res) { + const request = PasswordChangeRequest.fromParams(req, res) + + return Promise.resolve() + .then(() => request.validateToken()) + .then(() => request.renderForm()) + .catch(error => request.error(error)) + } + + static post (req, res) { + const request = PasswordChangeRequest.fromParams(req, res) + + return PasswordChangeRequest.handlePost(request) + } + + static handlePost (request) { + return Promise.resolve() + .then(() => request.validatePost()) + .then(() => request.validateToken()) + .then(tokenContents => request.changePassword(tokenContents)) + .then(() => request.renderSuccess()) + .catch(error => request.error(error)) + } + + validatePost () { + if (!this.newPassword) { + throw new Error('Please enter a new password') + } + } + + validateToken () { + return Promise.resolve() + .then(() => { + if (!this.token) { return } + + return this.accountManager.validateResetToken(this.token) + }) + .then(validToken => { + if (validToken) { + this.validToken = true + } + + return validToken + }) + .catch(error => { + this.token = null + throw error + }) + } + + changePassword (tokenContents) { + let user = this.accountManager.userAccountFrom(tokenContents) + + debug('Changing password for user:', user.webId) + + return this.userStore.findUser(user.id) + .then(userStoreEntry => { + if (userStoreEntry) { + return this.userStore.updatePassword(user, this.newPassword) + } else { + return this.userStore.createUser(user, this.newPassword) + } + }) + } + + error (error) { + this.renderForm(error) + } + + renderForm (error) { + let params = { + validToken: this.validToken, + returnToUrl: this.returnToUrl, + token: this.token + } + + if (error) { + params.error = error.message + this.response.status(error.statusCode || 400) + } + + this.response.render('auth/change-password', params) + } + + renderSuccess () { + this.response.render('auth/password-changed', { returnToUrl: this.returnToUrl }) + } +} + +module.exports = PasswordChangeRequest diff --git a/lib/requests/password-reset-email-request.js b/lib/requests/password-reset-email-request.js index 9e82c5f61..0a2fc5e46 100644 --- a/lib/requests/password-reset-email-request.js +++ b/lib/requests/password-reset-email-request.js @@ -120,7 +120,8 @@ class PasswordResetEmailRequest extends AuthRequest { let params = { error: error.message, - returnToUrl: this.returnToUrl + returnToUrl: this.returnToUrl, + multiUser: this.accountManager.multiUser } res.status(error.statusCode || 400) diff --git a/package.json b/package.json index 625548ea6..fd95f2470 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.5.4", + "oidc-auth-manager": "^0.6.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", diff --git a/test/unit/login-by-password-request.js b/test/unit/login-by-password-request.js index 2a80f52da..413108c53 100644 --- a/test/unit/login-by-password-request.js +++ b/test/unit/login-by-password-request.js @@ -213,7 +213,7 @@ describe('LoginByPasswordRequest', () => { request.findValidUser() .catch(error => { expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('User found but no password found') + expect(error.message).to.equal('User found but no password match') done() }) }) @@ -242,8 +242,10 @@ describe('LoginByPasswordRequest', () => { let mockUserStore beforeEach(() => { + let aliceRecord = { webId: 'https://alice.example.com/profile/card#me' } + mockUserStore = { - findUser: () => { return Promise.resolve(true) }, + findUser: sinon.stub().resolves(aliceRecord), matchPassword: (user, password) => { return Promise.resolve(user) } } }) @@ -252,12 +254,11 @@ describe('LoginByPasswordRequest', () => { let options = { username: 'alice', userStore: mockUserStore, accountManager } let request = new LoginByPasswordRequest(options) - let storeFindUser = sinon.spy(request.userStore, 'findUser') let userStoreKey = 'alice.example.com/profile/card#me' return request.findValidUser() .then(() => { - expect(storeFindUser).to.be.calledWith(userStoreKey) + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) }) }) @@ -266,12 +267,11 @@ describe('LoginByPasswordRequest', () => { let options = { username: webId, userStore: mockUserStore, accountManager } let request = new LoginByPasswordRequest(options) - let storeFindUser = sinon.spy(request.userStore, 'findUser') let userStoreKey = 'alice.example.com/profile/card#me' return request.findValidUser() .then(() => { - expect(storeFindUser).to.be.calledWith(userStoreKey) + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) }) }) }) From d170650a18abf9cf718e8cb907451f9db020a425 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 27 Apr 2017 15:30:44 -0400 Subject: [PATCH 041/178] Add support for --force-user flag for oidc auth --- config/defaults.js | 2 +- lib/api/authn/index.js | 19 ++++++++++++++++++- lib/create-app.js | 4 ++++ lib/models/account-manager.js | 2 -- test/integration/acl-tls.js | 3 ++- test/unit/create-account-request.js | 6 ++++-- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/config/defaults.js b/config/defaults.js index fcd7c1786..232176caf 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -1,7 +1,7 @@ 'use strict' module.exports = { - 'auth': 'tls', + 'auth': 'oidc', 'configPath': './config', 'dbPath': './.db', 'port': 8443, diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index cc13dd9fe..137af4e96 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -1,6 +1,23 @@ 'use strict' +const debug = require('../../debug').authentication + +/** + * Enforces the `--force-user` server flag, hardcoding a webid for all requests, + * for testing purposes. + */ +function overrideWith (forceUserId) { + return (req, res, next) => { + req.session.userId = forceUserId + req.session.identified = true + debug('Identified user (override): ' + forceUserId) + res.set('User', forceUserId) + return next() + } +} + module.exports = { oidc: require('./webid-oidc'), - tls: require('./webid-tls') + tls: require('./webid-tls'), + overrideWith } diff --git a/lib/create-app.js b/lib/create-app.js index e7171e245..b3b3c4cc8 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -203,6 +203,10 @@ function initAuthentication (argv, app) { // Enforce authentication with WebID-OIDC on all LDP routes app.use('/', oidc.rs.authenticate()) + + if (argv.forceUser) { + app.use('/', API.authn.overrideWith(argv.forceUser)) + } break default: throw new TypeError('Unsupported authentication scheme') diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 2a5ec0474..d6dfe6e78 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -489,8 +489,6 @@ class AccountManager { .then(resetToken => { let resetUrl = this.passwordResetUrl(resetToken, returnToUrl) - debug('Reset URL:', resetUrl) - let emailData = { to: userAccount.email, webId: userAccount.webId, diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 1f7aef69b..94aff40da 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -31,7 +31,8 @@ describe('ACL HTTP', function () { sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - strictOrigin: true + strictOrigin: true, + auth: 'tls' }) before(function (done) { diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index 6a9541ea2..778b0f1d5 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -68,8 +68,10 @@ describe('CreateAccountRequest', () => { describe('createAccount()', () => { it('should return a 400 error if account already exists', done => { let accountManager = AccountManager.from({ host }) - let locals = { authMethod: defaults.auth, accountManager } - let aliceData = { username: 'alice' } + let locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + let aliceData = { + username: 'alice', password: '1234' + } let req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) let request = CreateAccountRequest.fromParams(req, res) From b49082afd6672b15e5e9a4cc75af5b9188e048f2 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 27 Apr 2017 15:47:26 -0400 Subject: [PATCH 042/178] Fix account creation welcome email logic --- lib/requests/create-account-request.js | 2 ++ test/unit/create-account-request.js | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index 776d1c9a2..39226e244 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -254,6 +254,8 @@ class CreateOidcAccountRequest extends CreateAccountRequest { let redirectUrl = this.returnToUrl || this.accountManager.accountUriFor(userAccount.username) this.response.redirect(redirectUrl) + + return userAccount } } diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index 778b0f1d5..dcefcd804 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -175,8 +175,9 @@ describe('CreateOidcAccountRequest', () => { let request = CreateAccountRequest.fromParams(req, res) - request.sendResponse(alice) + let result = request.sendResponse(alice) expect(request.response.statusCode).to.equal(302) + expect(result.username).to.equal('alice') }) }) }) From 182cde78834db737493bccd97aada167d3b7e597 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 27 Apr 2017 15:59:42 -0400 Subject: [PATCH 043/178] Remove broken /messages api code --- lib/api/index.js | 1 - lib/api/messages/index.js | 104 --------------------------- lib/create-app.js | 4 -- test/integration/api-messages.js | 118 ------------------------------- 4 files changed, 227 deletions(-) delete mode 100644 lib/api/messages/index.js delete mode 100644 test/integration/api-messages.js diff --git a/lib/api/index.js b/lib/api/index.js index c05519028..5ce7a3514 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -2,7 +2,6 @@ module.exports = { authn: require('./authn'), - messages: require('./messages'), oidc: require('./authn/webid-oidc'), tls: require('./authn/webid-tls'), accounts: require('./accounts/user-accounts') diff --git a/lib/api/messages/index.js b/lib/api/messages/index.js deleted file mode 100644 index 79ddf69a6..000000000 --- a/lib/api/messages/index.js +++ /dev/null @@ -1,104 +0,0 @@ -exports.send = send - -const error = require('../../http-error') -const debug = require('debug')('solid:api:messages') -const utils = require('../../utils') -const sym = require('rdflib').sym -const url = require('url') -const waterfall = require('run-waterfall') - -function send () { - return (req, res, next) => { - if (!req.session.userId) { - next(error(401, 'You need to be authenticated')) - return - } - - if (!req.body.message || req.body.message.length < 0) { - next(error(406, 'You need to specify a message')) - return - } - - if (!req.body.to) { - next(error(406, 'You need to specify a the destination')) - return - } - - if (req.body.to.split(':').length !== 2) { - next(error(406, 'Destination badly formatted')) - return - } - - waterfall([ - (cb) => getLoggedUserName(req, cb), - (displayName, cb) => { - const vars = { - me: displayName, - message: req.body.message - } - - if (req.body.to.split(':') === 'mailto' && req.app.locals.emailService) { - sendEmail(req, vars, cb) - } else { - cb(error(406, 'Messaging service not available')) - } - } - ], (err) => { - if (err) { - next(err) - return - } - - res.send('message sent') - }) - } -} - -function getLoggedUserName (req, callback) { - const ldp = req.app.locals.ldp - const baseUri = utils.uriAbs(req) - const webid = url.parse(req.session.userId) - - ldp.graph(webid.hostname, '/' + webid.pathname, baseUri, function (err, graph) { - if (err) { - debug('cannot find graph of the user', req.session.userId || ldp.root, err) - // TODO for now only users of this IDP can send emails - callback(error(403, 'Your user cannot perform this operation')) - return - } - - // TODO do a query - let displayName - graph - .statementsMatching(undefined, sym('http://xmlns.com/foaf/0.1/name')) - .some(function (statement) { - if (statement.object.value) { - displayName = statement.object.value - return true - } - }) - - if (!displayName) { - displayName = webid.hostname - } - callback(null, displayName) - }) -} - -function sendEmail (req, vars, callback) { - const emailService = req.app.locals.emailService - const emailData = { - from: 'no-reply@' + webid.hostname, - to: req.body.to.split(':')[1] - } - const webid = url.parse(req.session.userId) - - emailService.messageTemplate((template) => { - var send = emailService.mailer.templateSender( - template, - { from: emailData.from }) - - // use template based sender to send a message - send({ to: emailData.to }, vars, callback) - }) -} diff --git a/lib/create-app.js b/lib/create-app.js index b3b3c4cc8..42e3c5c0f 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -15,7 +15,6 @@ const EmailService = require('./models/email-service') const TokenService = require('./models/token-service') const AccountRecovery = require('./account-recovery') const capabilityDiscovery = require('./capability-discovery') -const bodyParser = require('body-parser').urlencoded({ extended: false }) const API = require('./api') const errorPages = require('./handlers/error-pages') const OidcManager = require('./models/oidc-manager') @@ -171,9 +170,6 @@ function initWebId (argv, app, ldp) { if (argv.idp) { app.use(vhost('*', LdpMiddleware(corsSettings))) } - - // Messaging API - app.post('/api/messages', bodyParser, API.messages.send()) } /** diff --git a/test/integration/api-messages.js b/test/integration/api-messages.js deleted file mode 100644 index 0bc777642..000000000 --- a/test/integration/api-messages.js +++ /dev/null @@ -1,118 +0,0 @@ -// const Solid = require('../../index') -// const path = require('path') -// const hippie = require('hippie') -// const fs = require('fs-extra') -// In this test we always assume that we are Alice - -describe.skip('Messages API', () => { - // let aliceServer - - // const bobCert = { - // cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - // key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - // } - // const aliceCert = { - // cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - // key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - // } - // - // const rootPath = path.join(__dirname, '../resources/messaging-scenario') - // const alicePod = Solid.createServer({ - // root: rootPath, - // sslKey: path.join(__dirname, '../keys/key.pem'), - // sslCert: path.join(__dirname, '../keys/cert.pem'), - // auth: 'tls', - // dataBrowser: false, - // fileBrowser: false, - // webid: false, - // idp: true - // }) - // - // before((done) => { - // aliceServer = alicePod.listen(5000, done) - // }) - // - // after(function () { - // if (aliceServer) aliceServer.close() - // fs.removeSync(rootPath) - // }) - - // describe('endpoints', () => { - // describe('/api/messages', () => { - // it('should send 401 if user is not logged in', (done) => { - // hippie() - // .post('https://localhost:5000/api/messages') - // .expectStatus(401) - // .end(done) - // }) - // it('should send 406 if message is missing', (done) => { - // hippie() - // // .json() - // .use(function (options, next) { - // options.agentOptions = bobCert - // options.strictSSL = false - // next(options) - // }) - // .post('https://localhost:5000/api/messages') - // .expectStatus(406) - // .end(done) - // }) - // it('should send 403 user is not of this IDP', (done) => { - // hippie() - // // .json() - // .use(function (options, next) { - // options.agentOptions = bobCert - // options.strictSSL = false - // next(options) - // }) - // .form() - // .send({message: 'thisisamessage', to: 'mailto:mail@email.com'}) - // .post('https://localhost:5000/api/messages') - // .expectStatus(403) - // .end(done) - // }) - // it('should send 406 if not destination `to` is specified', (done) => { - // hippie() - // // .json() - // .use(function (options, next) { - // options.agentOptions = aliceCert - // options.strictSSL = false - // next(options) - // }) - // .form() - // .send({message: 'thisisamessage'}) - // .post('https://localhost:5000/api/messages') - // .expectStatus(406) - // .end(done) - // }) - // it('should send 406 if not destination `to` is missing the protocol', (done) => { - // hippie() - // // .json() - // .use(function (options, next) { - // options.agentOptions = aliceCert - // options.strictSSL = false - // next(options) - // }) - // .form() - // .send({message: 'thisisamessage', to: 'mail@email.com'}) - // .post('https://localhost:5000/api/messages') - // .expectStatus(406) - // .end(done) - // }) - // it('should send 406 if messaging protocol is not supported', (done) => { - // hippie() - // // .json() - // .use(function (options, next) { - // options.agentOptions = aliceCert - // options.strictSSL = false - // next(options) - // }) - // .form() - // .send({message: 'thisisamessage', to: 'email2:mail@email.com'}) - // .post('https://localhost:5000/api/messages') - // .expectStatus(406) - // .end(done) - // }) - // }) - // }) -}) From 477c0e0e5c67f67c74c4c05a51cb661da6604d17 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 27 Apr 2017 16:04:15 -0400 Subject: [PATCH 044/178] Remove old account-recovery handler --- lib/account-recovery.js | 115 ---------------------------------------- lib/create-app.js | 7 --- 2 files changed, 122 deletions(-) delete mode 100644 lib/account-recovery.js diff --git a/lib/account-recovery.js b/lib/account-recovery.js deleted file mode 100644 index 58d034afb..000000000 --- a/lib/account-recovery.js +++ /dev/null @@ -1,115 +0,0 @@ -module.exports = AccountRecovery - -const express = require('express') -const TokenService = require('./models/token-service') -const bodyParser = require('body-parser') -const path = require('path') -const debug = require('debug')('solid:account-recovery') -const utils = require('./utils') -const sym = require('rdflib').sym -const url = require('url') - -function AccountRecovery (options = {}) { - const router = express.Router('/') - const tokenService = new TokenService() - const generateEmail = function (host, account, email, token) { - return { - from: '"Account Recovery" ', - to: email, - subject: 'Recover your account', - text: 'Hello,\n' + - 'You asked to retrieve your account: ' + account + '\n' + - 'Copy this address in your browser addressbar:\n\n' + - 'https://' + path.join(host, '/api/accounts/validateToken?token=' + token) // TODO find a way to get the full url - // html: '' - } - } - - router.get('/recover', function (req, res, next) { - res.set('Content-Type', 'text/html') - res.sendFile(path.join(__dirname, '../static/account-recovery.html')) - }) - - router.post('/recover', bodyParser.urlencoded({ extended: false }), function (req, res, next) { - debug('getting request for account recovery', req.body.webid) - const ldp = req.app.locals.ldp - const emailService = req.app.locals.emailService - const baseUri = utils.uriAbs(req) - - // if (!req.body.webid) { - // res.status(406).send('You need to pass an account') - // return - // } - - // Check if account exists - let webid = url.parse(req.body.webid) - let hostname = webid.hostname - - ldp.graph(hostname, '/' + ldp.suffixAcl, baseUri, function (err, graph) { - if (err) { - debug('cannot find graph of the user', req.body.webid || ldp.root, err) - res.status(err.status || 500).send('Fail to find user') - return - } - - // TODO do a query - let emailAddress - graph - .statementsMatching(undefined, sym('http://www.w3.org/ns/auth/acl#agent')) - .some(function (statement) { - if (statement.object.uri.startsWith('mailto:')) { - emailAddress = statement.object.uri - return true - } - }) - - if (!emailAddress) { - res.status(406).send('No emailAddress registered in your account') - return - } - - const token = tokenService.generate({ webid: req.body.webid }) - const email = generateEmail(req.get('host'), req.body.webid, emailAddress, token) - emailService.sendMail(email, function (err, info) { - if (err) { - res.send(500, 'Failed to send the email for account recovery, try again') - return - } - - res.send('Requested') - }) - }) - }) - - router.get('/validateToken', function (req, res, next) { - if (!req.query.token) { - res.status(406).send('Token is required') - return - } - - const tokenContent = tokenService.verify(req.query.token) - - if (!tokenContent) { - debug('token was not found', tokenContent) - res.status(401).send('Token not valid') - return - } - - if (tokenContent && !tokenContent.webid) { - debug('token does not match account', tokenContent) - res.status(401).send('Token not valid') - return - } - - debug('token was valid', tokenContent) - - tokenService.remove(req.query.token) - - req.session.userId = tokenContent.webid // TODO add the full path - req.session.identified = true - res.set('User', tokenContent.webid) - res.redirect(options.redirect) - }) - - return router -} diff --git a/lib/create-app.js b/lib/create-app.js index 42e3c5c0f..5bb745836 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -13,7 +13,6 @@ const AccountManager = require('./models/account-manager') const vhost = require('vhost') const EmailService = require('./models/email-service') const TokenService = require('./models/token-service') -const AccountRecovery = require('./account-recovery') const capabilityDiscovery = require('./capability-discovery') const API = require('./api') const errorPages = require('./handlers/error-pages') @@ -144,12 +143,6 @@ function initWebId (argv, app, ldp) { const useSecureCookies = argv.webid // argv.webid forces https and secure cookies app.use(session(sessionSettings(useSecureCookies, argv.host))) - var accountRecovery = AccountRecovery({ redirect: '/' }) - // adds GET /api/accounts/recover - // adds POST /api/accounts/recover - // adds GET /api/accounts/validateToken - app.use('/api/accounts/', accountRecovery) - let accountManager = AccountManager.from({ authMethod: argv.auth, emailService: app.locals.emailService, From b7539541afc284c7a31ebbc9c13baa9ddcbf9481 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 28 Apr 2017 11:04:49 -0400 Subject: [PATCH 045/178] Remove WebID-TLS authentication code --- bin/lib/options.js | 6 +- lib/api/authn/index.js | 1 - lib/api/authn/webid-tls.js | 69 -- lib/api/index.js | 1 - lib/create-app.js | 4 - lib/create-server.js | 4 - lib/ldp.js | 4 - lib/models/account-manager.js | 2 +- lib/requests/create-account-request.js | 113 +-- test/integration/account-creation-tls.js | 227 ------ test/integration/acl-tls.js | 955 ----------------------- test/unit/account-manager.js | 2 +- test/unit/add-cert-request.js | 6 +- test/unit/create-account-request.js | 60 -- test/unit/email-welcome.js | 2 +- test/unit/user-accounts-api.js | 2 +- 16 files changed, 10 insertions(+), 1448 deletions(-) delete mode 100644 lib/api/authn/webid-tls.js delete mode 100644 test/integration/account-creation-tls.js delete mode 100644 test/integration/acl-tls.js diff --git a/bin/lib/options.js b/bin/lib/options.js index a712a537c..a202f8cba 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -64,13 +64,11 @@ module.exports = [ question: 'Select authentication strategy', type: 'list', choices: [ - 'WebID-OpenID Connect', - 'WebID-TLS' + 'WebID-OpenID Connect' ], - prompt: true, + prompt: false, default: 'WebID-OpenID Connect', filter: (value) => { - if (value === 'WebID-TLS') return 'tls' if (value === 'WebID-OpenID Connect') return 'oidc' }, when: (answers) => { diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index 137af4e96..87dd81118 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -18,6 +18,5 @@ function overrideWith (forceUserId) { module.exports = { oidc: require('./webid-oidc'), - tls: require('./webid-tls'), overrideWith } diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js deleted file mode 100644 index 189e1869c..000000000 --- a/lib/api/authn/webid-tls.js +++ /dev/null @@ -1,69 +0,0 @@ -module.exports = handler -module.exports.authenticate = authenticate - -var webid = require('webid/tls') -var debug = require('../../debug').authentication -var error = require('../../http-error') - -function authenticate () { - return handler -} - -function handler (req, res, next) { - var ldp = req.app.locals.ldp - - if (ldp.forceUser) { - req.session.userId = ldp.forceUser - req.session.identified = true - debug('Identified user: ' + req.session.userId) - res.set('User', req.session.userId) - return next() - } - - // No webid required? skip - if (!ldp.webid) { - setEmptySession(req) - return next() - } - - // User already logged in? skip - if (req.session.userId && req.session.identified) { - debug('User: ' + req.session.userId) - res.set('User', req.session.userId) - return next() - } - - if (ldp.auth === 'tls') { - var certificate = req.connection.getPeerCertificate() - // Certificate is empty? skip - if (certificate === null || Object.keys(certificate).length === 0) { - debug('No client certificate found in the request. Did the user click on a cert?') - setEmptySession(req) - return next() - } - - // Verify webid - webid.verify(certificate, function (err, result) { - if (err) { - debug('Error processing certificate: ' + err.message) - setEmptySession(req) - return next() - } - req.session.userId = result - req.session.identified = true - debug('Identified user: ' + req.session.userId) - res.set('User', req.session.userId) - return next() - }) - } else if (ldp.auth === 'oidc') { - setEmptySession(req) - return next() - } else { - return next(error(500, 'Authentication method not supported')) - } -} - -function setEmptySession (req) { - req.session.userId = '' - req.session.identified = false -} diff --git a/lib/api/index.js b/lib/api/index.js index 5ce7a3514..907a32be2 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -3,6 +3,5 @@ module.exports = { authn: require('./authn'), oidc: require('./authn/webid-oidc'), - tls: require('./authn/webid-tls'), accounts: require('./accounts/user-accounts') } diff --git a/lib/create-app.js b/lib/create-app.js index 5bb745836..7703bfb8f 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -175,10 +175,6 @@ function initAuthentication (argv, app) { let authMethod = argv.auth switch (authMethod) { - case 'tls': - // Enforce authentication with WebID-TLS on all LDP routes - app.use('/', API.tls.authenticate()) - break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) app.locals.oidc = oidc diff --git a/lib/create-server.js b/lib/create-server.js index 6e4c0b225..878687f19 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -58,10 +58,6 @@ function createServer (argv, app) { cert: cert } - if (ldp.webid && ldp.auth === 'tls') { - credentials.requestCert = true - } - server = https.createServer(credentials, app) } diff --git a/lib/ldp.js b/lib/ldp.js index 239ba0920..8b802dd3d 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -75,10 +75,6 @@ class LDP { this.skin = true } - if (this.webid && !this.auth) { - this.auth = 'tls' - } - if (this.proxy && this.proxy[ 0 ] !== '/') { this.proxy = '/' + this.proxy } diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index d6dfe6e78..5d302f7b7 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -23,7 +23,7 @@ class AccountManager { /** * @constructor * @param [options={}] {Object} - * @param [options.authMethod] {string} Primary authentication method (e.g. 'tls') + * @param [options.authMethod] {string} Primary authentication method (e.g. 'oidc') * @param [options.emailService] {EmailService} * @param [options.tokenService] {TokenService} * @param [options.host] {SolidHost} diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index 39226e244..607cd03e0 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -1,7 +1,6 @@ 'use strict' const AuthRequest = require('./auth-request') -const WebIdTlsCertificate = require('../models/webid-tls-certificate') const debug = require('../debug').accounts /** @@ -12,7 +11,7 @@ const debug = require('../debug').accounts * a command line, use the `AccountManager` class directly. * * This is an abstract class, subclasses are created (for example - * `CreateTlsAccountRequest`) depending on which Authentication mode the server + * `CreateOidcAccountRequest`) depending on which Authentication mode the server * is running in. * * @class CreateAccountRequest @@ -73,9 +72,6 @@ class CreateAccountRequest extends AuthRequest { options.password = req.body.password options.userStore = locals.oidc.users return new CreateOidcAccountRequest(options) - case 'tls': - options.spkac = req.body.spkac - return new CreateTlsAccountRequest(options) default: throw new TypeError('Unsupported authentication scheme') } @@ -259,112 +255,5 @@ class CreateOidcAccountRequest extends CreateAccountRequest { } } -/** - * Models a Create Account request for a server using WebID-TLS as primary - * authentication mode. Handles generating and saving a TLS certificate, etc. - * - * @class CreateTlsAccountRequest - * @extends CreateAccountRequest - */ -class CreateTlsAccountRequest extends CreateAccountRequest { - /** - * @constructor - * - * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring - * @param [options.spkac] {string} - */ - constructor (options = {}) { - super(options) - this.spkac = options.spkac - this.certificate = null - } - - /** - * Generates a new X.509v3 RSA certificate (if `spkac` was passed in) and - * adds it to the user account. Used for storage in an agent's WebID - * Profile, for WebID-TLS authentication. - * - * @param userAccount {UserAccount} - * @param userAccount.webId {string} An agent's WebID URI - * - * @throws {Error} HTTP 400 error if errors were encountering during - * certificate generation. - * - * @return {Promise} Chainable - */ - generateTlsCertificate (userAccount) { - if (!this.spkac) { - debug('Missing spkac param, not generating cert during account creation') - return Promise.resolve(userAccount) - } - - return Promise.resolve() - .then(() => { - let host = this.accountManager.host - return WebIdTlsCertificate.fromSpkacPost(this.spkac, userAccount, host) - .generateCertificate() - }) - .catch(err => { - err.status = 400 - err.message = 'Error generating a certificate: ' + err.message - throw err - }) - .then(certificate => { - debug('Generated a WebID-TLS certificate as part of account creation') - this.certificate = certificate - return userAccount - }) - } - - /** - * Generates a WebID-TLS certificate and saves it to the user's profile - * graph. - * - * @param userAccount {UserAccount} - * - * @return {Promise} Chainable - */ - saveCredentialsFor (userAccount) { - return this.generateTlsCertificate(userAccount) - .then(userAccount => { - if (this.certificate) { - return this.accountManager - .addCertKeyToProfile(this.certificate, userAccount) - .then(() => { - debug('Saved generated WebID-TLS certificate to profile') - }) - } else { - debug('No certificate generated, no need to save to profile') - } - }) - .then(() => { - return userAccount - }) - } - - /** - * Writes the generated TLS certificate to the http Response object. - * - * @param userAccount {UserAccount} - * - * @return {UserAccount} Chainable - */ - sendResponse (userAccount) { - let res = this.response - res.set('User', userAccount.webId) - res.status(200) - - if (this.certificate) { - res.set('Content-Type', 'application/x-x509-user-cert') - res.send(this.certificate.toDER()) - } else { - res.end() - } - - return userAccount - } -} - module.exports = CreateAccountRequest module.exports.CreateAccountRequest = CreateAccountRequest -module.exports.CreateTlsAccountRequest = CreateTlsAccountRequest diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls.js deleted file mode 100644 index dfc710cef..000000000 --- a/test/integration/account-creation-tls.js +++ /dev/null @@ -1,227 +0,0 @@ -const supertest = require('supertest') -// Helper functions for the FS -const $rdf = require('rdflib') - -const { rm, read } = require('../test-utils') -const ldnode = require('../../index') -const fs = require('fs-extra') -const path = require('path') - -describe('AccountManager (account creation tests)', function () { - this.timeout(10000) - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - - var address = 'https://localhost:3457' - var host = 'localhost:3457' - var ldpHttpsServer - let rootPath = path.join(__dirname, '../resources/accounts/') - var ldp = ldnode.createServer({ - root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - auth: 'tls', - webid: true, - idp: true, - strictOrigin: true - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(3457, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(rootPath, 'localhost/index.html')) - fs.removeSync(path.join(rootPath, 'localhost/index.html.acl')) - }) - - var server = supertest(address) - - it('should expect a 404 on GET /accounts', function (done) { - server.get('/api/accounts') - .expect(404, done) - }) - - describe('accessing accounts', function () { - it('should be able to access public file of an account', function (done) { - var subdomain = supertest('https://tim.' + host) - subdomain.get('/hello.html') - .expect(200, done) - }) - it('should get 404 if root does not exist', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.get('/') - .set('Accept', 'text/turtle') - .set('Origin', 'http://example.com') - .expect(404) - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect('Access-Control-Allow-Credentials', 'true') - .end(function (err, res) { - done(err) - }) - }) - }) - - describe('generating a certificate', () => { - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - after(function () { - rm('accounts/nicola.localhost') - }) - - it('should generate a certificate if spkac is valid', (done) => { - var spkac = read('example_spkac.cnf') - var subdomain = supertest.agent('https://nicola.' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect('Content-Type', /application\/x-x509-user-cert/) - .expect(200, done) - }) - - it('should not generate a certificate if spkac is not valid', (done) => { - var subdomain = supertest('https://nicola.' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola') - .expect(200) - .end((err) => { - if (err) return done(err) - - subdomain.post('/api/accounts/cert') - .send('username=nicola&spkac=') - .expect(400, done) - }) - }) - }) - - describe('creating an account with POST', function () { - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - - after(function () { - rm('accounts/nicola.localhost') - }) - - it('should not create WebID if no username is given', (done) => { - let subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=&spkac=' + spkac) - .expect(400, done) - }) - - it('should not create a WebID if it already exists', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end((err) => { - if (err) { - return done(err) - } - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(400) - .end((err) => { - done(err) - }) - }) - }) - - it('should create the default folders', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end(function (err) { - if (err) { - return done(err) - } - var domain = host.split(':')[0] - var card = read(path.join('accounts/nicola.' + domain, - 'profile/card')) - var cardAcl = read(path.join('accounts/nicola.' + domain, - 'profile/card.acl')) - var prefs = read(path.join('accounts/nicola.' + domain, - 'settings/prefs.ttl')) - var inboxAcl = read(path.join('accounts/nicola.' + domain, - 'inbox/.acl')) - var rootMeta = read(path.join('accounts/nicola.' + domain, '.meta')) - var rootMetaAcl = read(path.join('accounts/nicola.' + domain, - '.meta.acl')) - - if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && - rootMetaAcl) { - done() - } else { - done(new Error('failed to create default files')) - } - }) - }) - - it('should link WebID to the root account', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end(function (err) { - if (err) { - return done(err) - } - subdomain.get('/.meta') - .expect(200) - .end(function (err, data) { - if (err) { - return done(err) - } - var graph = $rdf.graph() - $rdf.parse( - data.text, - graph, - 'https://nicola.' + host + '/.meta', - 'text/turtle') - var statements = graph.statementsMatching( - undefined, - $rdf.sym('http://www.w3.org/ns/solid/terms#account'), - undefined) - if (statements.length === 1) { - done() - } else { - done(new Error('missing link to WebID of account')) - } - }) - }) - }) - - it('should create a private settings container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/settings/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private prefs file in the settings container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/prefs.ttl') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private inbox container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - }) -}) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js deleted file mode 100644 index 94aff40da..000000000 --- a/test/integration/acl-tls.js +++ /dev/null @@ -1,955 +0,0 @@ -var assert = require('chai').assert -var fs = require('fs-extra') -var $rdf = require('rdflib') -var request = require('request') -var path = require('path') - -/** - * Note: this test suite requires an internet connection, since it actually - * uses remote accounts https://user1.databox.me and https://user2.databox.me - */ - -// Helper functions for the FS -var rm = require('../test-utils').rm -// var write = require('./test-utils').write -// var cp = require('./test-utils').cp -// var read = require('./test-utils').read - -var ldnode = require('../../index') -var ns = require('solid-namespace')($rdf) - -describe('ACL HTTP', function () { - this.timeout(10000) - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - - var address = 'https://localhost:3456/test/' - let rootPath = path.join(__dirname, '../resources') - var ldpHttpsServer - var ldp = ldnode.createServer({ - mount: '/test', - root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - webid: true, - strictOrigin: true, - auth: 'tls' - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(3456, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(rootPath, 'index.html')) - fs.removeSync(path.join(rootPath, 'index.html.acl')) - }) - - var aclExtension = '.acl' - var metaExtension = '.meta' - - var testDir = 'acl-tls/testDir' - var testDirAclFile = testDir + '/' + aclExtension - var testDirMetaFile = testDir + '/' + metaExtension - - var abcFile = testDir + '/abc.ttl' - var abcAclFile = abcFile + aclExtension - - var globFile = testDir + '/*' - - var groupFile = testDir + '/group' - - var origin1 = 'http://example.org/' - var origin2 = 'http://example.com/' - - var user1 = 'https://user1.databox.me/profile/card#me' - var user2 = 'https://user2.databox.me/profile/card#me' - var userCredentials = { - user1: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - }, - user2: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - } - } - - function createOptions (path, user) { - var options = { - url: address + path, - headers: { - accept: 'text/turtle' - } - } - if (user) { - options.agentOptions = userCredentials[user] - } - return options - } - - describe('no ACL', function () { - it('should return 403 for any resource', function (done) { - var options = createOptions('/acl-tls/no-acl/', 'user1') - request(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should have `User` set in the Response Header', function (done) { - var options = createOptions('/acl-tls/no-acl/', 'user1') - request(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) - - describe('empty .acl', function () { - describe('with no defaultForNew in parent path', function () { - it('should give no access', function (done) { - var options = createOptions('/acl-tls/empty-acl/test-folder', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not let edit the .acl', function (done) { - var options = createOptions('/acl-tls/empty-acl/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not let read the .acl', function (done) { - var options = createOptions('/acl-tls/empty-acl/.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) - describe('with defaultForNew in parent path', function () { - before(function () { - rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') - rm('/acl-tls/write-acl/empty-acl/test-file') - rm('/acl-tls/write-acl/test-file') - rm('/acl-tls/write-acl/test-file.acl') - }) - - it('should fail to create a container', function (done) { - var options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 409) - done() - }) - }) - it('should allow creation of new files', function (done) { - var options = createOptions('/acl-tls/write-acl/empty-acl/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('should allow creation of new files in deeper paths', function (done) { - var options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('Should create empty acl file', function (done) { - var options = createOptions('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('should return text/turtle for the acl file', function (done) { - var options = createOptions('/acl-tls/write-acl/.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - assert.match(response.headers['content-type'], /text\/turtle/) - done() - }) - }) - it('should create test file', function (done) { - var options = createOptions('/acl-tls/write-acl/test-file', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("should create test file's acl file", function (done) { - var options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("should access test file's acl file", function (done) { - var options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - assert.match(response.headers['content-type'], /text\/turtle/) - done() - }) - }) - - after(function () { - rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') - rm('/acl-tls/write-acl/empty-acl/test-file') - rm('/acl-tls/write-acl/test-file') - rm('/acl-tls/write-acl/test-file.acl') - }) - }) - }) - - describe('Origin', function () { - before(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - - it('should PUT new ACL file', function (done) { - var options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '<#Owner> a ;\n' + - ' ;\n' + - ' <' + user1 + '>;\n' + - ' <' + origin1 + '>;\n' + - ' , , .\n' + - '<#Public> a ;\n' + - ' <./>;\n' + - ' ;\n' + - ' <' + origin1 + '>;\n' + - ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - // TODO triple header - // TODO user header - }) - }) - it('user1 should be able to access test directory', function (done) { - var options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access to test directory when origin is valid', - function (done) { - var options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be denied access to test directory when origin is invalid', - function (done) { - var options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should be able to access test directory', function (done) { - var options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should be able to access to test directory when origin is valid', - function (done) { - var options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should be denied access to test directory when origin is invalid', - function (done) { - var options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - - after(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - }) - - describe('Read-only', function () { - var body = fs.readFileSync(path.join(__dirname, '../resources/acl-tls/read-acl/.acl')) - it('user1 should be able to access ACL file', function (done) { - var options = createOptions('/acl-tls/read-acl/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test directory', function (done) { - var options = createOptions('/acl-tls/read-acl/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify ACL file', function (done) { - var options = createOptions('/acl-tls/read-acl/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should be able to access test directory', function (done) { - var options = createOptions('/acl-tls/read-acl/', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to access ACL file', function (done) { - var options = createOptions('/acl-tls/read-acl/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should not be able to modify ACL file', function (done) { - var options = createOptions('/acl-tls/read-acl/.acl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should be able to access test direcotory', function (done) { - var options = createOptions('/acl-tls/read-acl/') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify ACL file', function (done) { - var options = createOptions('/acl-tls/read-acl/.acl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - }) - - describe.skip('Glob', function () { - it('user2 should be able to send glob request', function (done) { - var options = createOptions(globFile, 'user2') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - var globGraph = $rdf.graph() - $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') - var authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) - assert.equal(authz, null) - done() - }) - }) - it('user1 should be able to send glob request', function (done) { - var options = createOptions(globFile, 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - var globGraph = $rdf.graph() - $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') - var authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) - assert.equal(authz, null) - done() - }) - }) - it('user1 should be able to delete ACL file', function (done) { - var options = createOptions(testDirAclFile, 'user1') - request.del(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - }) - - describe('Append-only', function () { - // var body = fs.readFileSync(__dirname + '/resources/acl-tls/append-acl/abc.ttl.acl') - it("user1 should be able to access test file's ACL file", function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user1') - request.head(options, function (error, response) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to PATCH a resource', function (done) { - var options = createOptions('/acl-tls/append-inherited/test.ttl', 'user1') - options.headers = { - 'content-type': 'application/sparql-update' - } - options.body = 'INSERT DATA { :test :hello 456 .}' - request.patch(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - // TODO POST instead of PUT - it('user1 should be able to modify test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user2 should not be able to access test file's ACL file", function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should not be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 (with append permission) cannot use PUT to append', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent (with append permissions) should not PUT', function (done) { - var options = createOptions('/acl-tls/append-acl/abc.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - after(function () { - rm('acl-tls/append-inherited/test.ttl') - }) - }) - - describe('Restricted', function () { - var body = '<#Owner> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user1 + '>;\n' + - ' , , .\n' + - '<#Restricted> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user2 + '>;\n' + - ' , .\n' - it("user1 should be able to modify test file's ACL file", function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user1 should be able to access test file's ACL file", function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user2 should not be able to access test file's ACL file", function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should be able to modify test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent should not be able to modify test file', function (done) { - var options = createOptions('/acl-tls/append-acl/abc2.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - }) - - describe.skip('Group', function () { - var groupTriples = '<#> a ;\n' + - ' , , <' + user2 + '> .\n' - var body = '<#Owner>\n' + - ' <' + address + abcFile + '>, <' + - address + abcAclFile + '>;\n' + - ' <' + user1 + '>;\n' + - ' <' + address + testDir + '>;\n' + - ' , .\n' + - '<#Group>\n' + - ' <' + address + abcFile + '>;\n' + - ' <' + address + groupFile + '#>;\n' + - ' .\n' - it('user1 should be able to add group triples', function (done) { - var options = createOptions(groupFile, 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = groupTriples - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user1 should be able to modify test file's ACL file", function (done) { - var options = createOptions(abcAclFile, 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - - it("user1 should be able to access test file's ACL file", function (done) { - var options = createOptions(abcAclFile, 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify test file', function (done) { - var options = createOptions(abcFile, 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - var options = createOptions(abcFile, 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user2 should not be able to access test file's ACL file", function (done) { - var options = createOptions(abcAclFile, 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should be able to access test file', function (done) { - var options = createOptions(abcFile, 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to modify test file', function (done) { - var options = createOptions(abcFile, 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - var options = createOptions(abcFile) - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent should not be able to modify test file', function (done) { - var options = createOptions(abcFile) - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('user1 should be able to delete group file', function (done) { - var options = createOptions(groupFile, 'user1') - request.del(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user1 should be able to delete test file's ACL file", function (done) { - var options = createOptions(abcAclFile, 'user1') - request.del(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - }) - - describe('defaultForNew', function () { - before(function () { - rm('/acl-tls/write-acl/default-for-new/.acl') - rm('/acl-tls/write-acl/default-for-new/test-file.ttl') - }) - - var body = '<#Owner> a ;\n' + - ' <./>;\n' + - ' <' + user1 + '>;\n' + - ' <./>;\n' + - ' , , .\n' + - '<#Default> a ;\n' + - ' <./>;\n' + - ' <./>;\n' + - ' ;\n' + - ' .\n' - it("user1 should be able to modify test directory's ACL file", function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user1 should be able to access test direcotory's ACL file", function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to create new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to access new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user2 should not be able to access test direcotory's ACL file", function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should be able to access new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to modify new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should be able to access new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify new test file', function (done) { - var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - - after(function () { - rm('/acl-tls/write-acl/default-for-new/.acl') - rm('/acl-tls/write-acl/default-for-new/test-file.ttl') - }) - }) - - describe('WebID delegation tests', function () { - it('user1 should be able delegate to user2', function (done) { - // var body = '<' + user1 + '> <' + user2 + '> .' - var options = { - url: user1, - headers: { - 'content-type': 'text/turtle' - }, - agentOptions: { - key: userCredentials.user1.key, - cert: userCredentials.user1.cert - } - } - request.post(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - // it("user2 should be able to make requests on behalf of user1", function(done) { - // var options = createOptions(abcdFile, 'user2') - // options.headers = { - // 'content-type': 'text/turtle', - // 'On-Behalf-Of': '<' + user1 + '>' - // } - // options.body = " ." - // request.post(options, function(error, response, body) { - // assert.equal(error, null) - // assert.equal(response.statusCode, 200) - // done() - // }) - // }) - }) - - describe.skip('Cleanup', function () { - it('should remove all files and dirs created', function (done) { - try { - // must remove the ACLs in sync - fs.unlinkSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/abcd.ttl')) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/')) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/')) - fs.unlinkSync(path.join(__dirname, '../resources/' + abcFile)) - fs.unlinkSync(path.join(__dirname, '../resources/' + testDirAclFile)) - fs.unlinkSync(path.join(__dirname, '../resources/' + testDirMetaFile)) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir)) - fs.rmdirSync(path.join(__dirname, '../resources/acl-tls/')) - done() - } catch (e) { - done(e) - } - }) - }) -}) diff --git a/test/unit/account-manager.js b/test/unit/account-manager.js index 0d9990e09..7eb5e5aa4 100644 --- a/test/unit/account-manager.js +++ b/test/unit/account-manager.js @@ -30,7 +30,7 @@ describe('AccountManager', () => { it('should init with passed in options', () => { let config = { host, - authMethod: 'tls', + authMethod: 'oidc', multiUser: true, store: {}, emailService: {}, diff --git a/test/unit/add-cert-request.js b/test/unit/add-cert-request.js index 2baf01883..05d907859 100644 --- a/test/unit/add-cert-request.js +++ b/test/unit/add-cert-request.js @@ -31,7 +31,7 @@ describe('AddCertificateRequest', () => { describe('fromParams()', () => { it('should throw a 401 error if session.userId is missing', () => { let multiUser = true - let options = { host, multiUser, authMethod: 'tls' } + let options = { host, multiUser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = { @@ -52,7 +52,7 @@ describe('AddCertificateRequest', () => { let multiUser = true it('should call certificate.generateCertificate()', () => { - let options = { host, multiUser, authMethod: 'tls' } + let options = { host, multiUser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = { @@ -81,7 +81,7 @@ describe('AddCertificateRequest', () => { let multiUser = true it('should add certificate data to a graph', () => { - let options = { host, multiUser, authMethod: 'tls' } + let options = { host, multiUser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let userData = { username: 'alice' } diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index dcefcd804..b41a76b35 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -46,15 +46,6 @@ describe('CreateAccountRequest', () => { it('should create subclass depending on authMethod', () => { let request, aliceData, req - aliceData = { username: 'alice' } - req = HttpMocks.createRequest({ - app: { locals: { accountManager } }, body: aliceData, session - }) - req.app.locals.authMethod = 'tls' - - request = CreateAccountRequest.fromParams(req, res, accountManager) - expect(request).to.respondTo('generateTlsCertificate') - aliceData = { username: 'alice', password: '12345' } req = HttpMocks.createRequest({ app: { locals: { accountManager, oidc: {} } }, body: aliceData, session @@ -181,54 +172,3 @@ describe('CreateOidcAccountRequest', () => { }) }) }) - -describe('CreateTlsAccountRequest', () => { - let authMethod = 'tls' - let host, store - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - session = {} - res = HttpMocks.createResponse() - }) - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice' } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - let request = CreateAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.spkac).to.equal(aliceData.spkac) - }) - }) - - describe('saveCredentialsFor()', () => { - it('should call generateTlsCertificate()', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice' } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - let request = CreateAccountRequest.fromParams(req, res) - let userAccount = accountManager.userAccountFrom(aliceData) - - let generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') - - return request.saveCredentialsFor(userAccount) - .then(() => { - expect(generateTlsCertificate).to.have.been.calledWith(userAccount) - }) - }) - }) -}) diff --git a/test/unit/email-welcome.js b/test/unit/email-welcome.js index 189a3285a..e81769b70 100644 --- a/test/unit/email-welcome.js +++ b/test/unit/email-welcome.js @@ -25,7 +25,7 @@ beforeEach(() => { let mgrConfig = { host, emailService, - authMethod: 'tls', + authMethod: 'oidc', multiUser: true } accountManager = AccountManager.from(mgrConfig) diff --git a/test/unit/user-accounts-api.js b/test/unit/user-accounts-api.js index 318843215..4436064fd 100644 --- a/test/unit/user-accounts-api.js +++ b/test/unit/user-accounts-api.js @@ -29,7 +29,7 @@ describe('api/accounts/user-accounts', () => { let store = new LDP({ root: testAccountsDir, idp: multiUser }) it('should throw a 400 error if spkac param is missing', done => { - let options = { host, store, multiUser, authMethod: 'tls' } + let options = { host, store, multiUser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = { From 6b39dcfa8e6699cf8a692c06bab4bfb4b691cac3 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 28 Apr 2017 14:21:10 -0400 Subject: [PATCH 046/178] Clean up params integration tests --- test/integration/params.js | 22 ++++++++++++++----- test/resources/acl-tls/append-acl/abc.ttl | 1 - test/resources/acl-tls/append-acl/abc.ttl.acl | 8 ------- test/resources/acl-tls/append-acl/abc2.ttl | 1 - .../resources/acl-tls/append-acl/abc2.ttl.acl | 8 ------- test/resources/acl-tls/append-inherited/.acl | 13 ----------- test/resources/acl-tls/empty-acl/.acl | 0 test/resources/acl-tls/fake-account/.acl | 5 ----- .../resources/acl-tls/fake-account/hello.html | 9 -------- test/resources/acl-tls/no-acl/test-file.html | 1 - test/resources/acl-tls/origin/.acl | 5 ----- test/resources/acl-tls/owner-only/.acl | 5 ----- test/resources/acl-tls/read-acl/.acl | 10 --------- test/resources/acl-tls/write-acl/.acl | 5 ----- .../acl-tls/write-acl/empty-acl/.acl | 0 15 files changed, 17 insertions(+), 76 deletions(-) delete mode 100644 test/resources/acl-tls/append-acl/abc.ttl delete mode 100644 test/resources/acl-tls/append-acl/abc.ttl.acl delete mode 100644 test/resources/acl-tls/append-acl/abc2.ttl delete mode 100644 test/resources/acl-tls/append-acl/abc2.ttl.acl delete mode 100644 test/resources/acl-tls/append-inherited/.acl delete mode 100644 test/resources/acl-tls/empty-acl/.acl delete mode 100644 test/resources/acl-tls/fake-account/.acl delete mode 100644 test/resources/acl-tls/fake-account/hello.html delete mode 100644 test/resources/acl-tls/no-acl/test-file.html delete mode 100644 test/resources/acl-tls/origin/.acl delete mode 100644 test/resources/acl-tls/owner-only/.acl delete mode 100644 test/resources/acl-tls/read-acl/.acl delete mode 100644 test/resources/acl-tls/write-acl/.acl delete mode 100644 test/resources/acl-tls/write-acl/empty-acl/.acl diff --git a/test/integration/params.js b/test/integration/params.js index 4d58100c9..aeedd8cb3 100644 --- a/test/integration/params.js +++ b/test/integration/params.js @@ -82,9 +82,11 @@ describe('LDNODE params', function () { }) describe('ui-path', function () { + let rootPath = './test/resources/' var ldp = ldnode({ - root: './test/resources/', - apiApps: path.join(__dirname, '../resources/sampleContainer') + root: rootPath, + apiApps: path.join(__dirname, '../resources/sampleContainer'), + webid: false }) var server = supertest(ldp) @@ -97,9 +99,19 @@ describe('LDNODE params', function () { describe('forcedUser', function () { var ldpHttpsServer - let rootPath = path.join(__dirname, '../resources/acl-tls/fake-account') + + const port = 7777 + const serverUri = `https://localhost:7777` + const rootPath = path.join(__dirname, '../resources/accounts-acl') + const dbPath = path.join(rootPath, 'db') + const configPath = path.join(rootPath, 'config') + var ldp = ldnode.createServer({ forceUser: 'https://fakeaccount.com/profile#me', + dbPath, + configPath, + serverUri, + port, root: rootPath, sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), @@ -108,7 +120,7 @@ describe('LDNODE params', function () { }) before(function (done) { - ldpHttpsServer = ldp.listen(3459, done) + ldpHttpsServer = ldp.listen(port, done) }) after(function () { @@ -117,7 +129,7 @@ describe('LDNODE params', function () { fs.removeSync(path.join(rootPath, 'index.html.acl')) }) - var server = supertest('https://localhost:3459') + var server = supertest(serverUri) it('should find resource in correct path', function (done) { server.get('/hello.html') diff --git a/test/resources/acl-tls/append-acl/abc.ttl b/test/resources/acl-tls/append-acl/abc.ttl deleted file mode 100644 index 5296a5255..000000000 --- a/test/resources/acl-tls/append-acl/abc.ttl +++ /dev/null @@ -1 +0,0 @@ - . diff --git a/test/resources/acl-tls/append-acl/abc.ttl.acl b/test/resources/acl-tls/append-acl/abc.ttl.acl deleted file mode 100644 index ab1b8dd09..000000000 --- a/test/resources/acl-tls/append-acl/abc.ttl.acl +++ /dev/null @@ -1,8 +0,0 @@ -<#Owner> a ; - <./abc.ttl>; - ; - , , . -<#AppendOnly> a ; - <./abc.ttl>; - ; - . diff --git a/test/resources/acl-tls/append-acl/abc2.ttl b/test/resources/acl-tls/append-acl/abc2.ttl deleted file mode 100644 index 07eff8ea5..000000000 --- a/test/resources/acl-tls/append-acl/abc2.ttl +++ /dev/null @@ -1 +0,0 @@ - . diff --git a/test/resources/acl-tls/append-acl/abc2.ttl.acl b/test/resources/acl-tls/append-acl/abc2.ttl.acl deleted file mode 100644 index 873a63908..000000000 --- a/test/resources/acl-tls/append-acl/abc2.ttl.acl +++ /dev/null @@ -1,8 +0,0 @@ -<#Owner> a ; - <./abc2.ttl>; - ; - , , . -<#Restricted> a ; - <./abc2.ttl>; - ; - , . diff --git a/test/resources/acl-tls/append-inherited/.acl b/test/resources/acl-tls/append-inherited/.acl deleted file mode 100644 index 9bfe07b39..000000000 --- a/test/resources/acl-tls/append-inherited/.acl +++ /dev/null @@ -1,13 +0,0 @@ -@prefix acl: . - -<#authorization1> - a acl:Authorization; - - acl:agent - ; - acl:accessTo <./>; - acl:mode - acl:Read, acl:Write, acl:Control; - - acl:defaultForNew <./>. - diff --git a/test/resources/acl-tls/empty-acl/.acl b/test/resources/acl-tls/empty-acl/.acl deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/resources/acl-tls/fake-account/.acl b/test/resources/acl-tls/fake-account/.acl deleted file mode 100644 index f49950774..000000000 --- a/test/resources/acl-tls/fake-account/.acl +++ /dev/null @@ -1,5 +0,0 @@ -<#0> - a ; - <./> ; - ; - , . diff --git a/test/resources/acl-tls/fake-account/hello.html b/test/resources/acl-tls/fake-account/hello.html deleted file mode 100644 index 7fd820ca9..000000000 --- a/test/resources/acl-tls/fake-account/hello.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - Hello - - -Hello - - \ No newline at end of file diff --git a/test/resources/acl-tls/no-acl/test-file.html b/test/resources/acl-tls/no-acl/test-file.html deleted file mode 100644 index 16b832e3f..000000000 --- a/test/resources/acl-tls/no-acl/test-file.html +++ /dev/null @@ -1 +0,0 @@ -test-file.html \ No newline at end of file diff --git a/test/resources/acl-tls/origin/.acl b/test/resources/acl-tls/origin/.acl deleted file mode 100644 index 52aa6cc78..000000000 --- a/test/resources/acl-tls/origin/.acl +++ /dev/null @@ -1,5 +0,0 @@ -<#0> - a ; - <./> ; - ; - , . diff --git a/test/resources/acl-tls/owner-only/.acl b/test/resources/acl-tls/owner-only/.acl deleted file mode 100644 index 52aa6cc78..000000000 --- a/test/resources/acl-tls/owner-only/.acl +++ /dev/null @@ -1,5 +0,0 @@ -<#0> - a ; - <./> ; - ; - , . diff --git a/test/resources/acl-tls/read-acl/.acl b/test/resources/acl-tls/read-acl/.acl deleted file mode 100644 index c1ff5f067..000000000 --- a/test/resources/acl-tls/read-acl/.acl +++ /dev/null @@ -1,10 +0,0 @@ -<#Owner> - a ; - <./>; - ; - , , . -<#Public> - a ; - <./>; - ; - . \ No newline at end of file diff --git a/test/resources/acl-tls/write-acl/.acl b/test/resources/acl-tls/write-acl/.acl deleted file mode 100644 index 52aa6cc78..000000000 --- a/test/resources/acl-tls/write-acl/.acl +++ /dev/null @@ -1,5 +0,0 @@ -<#0> - a ; - <./> ; - ; - , . diff --git a/test/resources/acl-tls/write-acl/empty-acl/.acl b/test/resources/acl-tls/write-acl/empty-acl/.acl deleted file mode 100644 index e69de29bb..000000000 From 917cd29da74d2442ca8eadf337a061be621c93c9 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Fri, 28 Apr 2017 15:43:41 -0400 Subject: [PATCH 047/178] Add tests for PasswordChangeRequest handler --- lib/models/account-manager.js | 2 +- lib/requests/password-change-request.js | 2 +- test/unit/password-change-request.js | 261 ++++++++++++++++++ ...est.js => password-reset-email-request.js} | 0 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 test/unit/password-change-request.js rename test/unit/{password-reset-request.js => password-reset-email-request.js} (100%) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 5d302f7b7..6026599ef 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -413,7 +413,7 @@ class AccountManager { * * @throws {Error} If missing or invalid token * - * @return {Object} Saved token data object + * @return {Object|false} Saved token data object if verified, false otherwise */ validateResetToken (token) { let tokenValue = this.tokenService.verify(token) diff --git a/lib/requests/password-change-request.js b/lib/requests/password-change-request.js index 96f1c20ec..2c82a82eb 100644 --- a/lib/requests/password-change-request.js +++ b/lib/requests/password-change-request.js @@ -90,7 +90,7 @@ class PasswordChangeRequest extends AuthRequest { validateToken () { return Promise.resolve() .then(() => { - if (!this.token) { return } + if (!this.token) { return false } return this.accountManager.validateResetToken(this.token) }) diff --git a/test/unit/password-change-request.js b/test/unit/password-change-request.js new file mode 100644 index 000000000..cd566b519 --- /dev/null +++ b/test/unit/password-change-request.js @@ -0,0 +1,261 @@ +'use strict' + +const chai = require('chai') +const sinon = require('sinon') +const expect = chai.expect +const dirtyChai = require('dirty-chai') +chai.use(dirtyChai) +const sinonChai = require('sinon-chai') +chai.use(sinonChai) +chai.should() + +const HttpMocks = require('node-mocks-http') + +const PasswordChangeRequest = require('../../lib/requests/password-change-request') +const SolidHost = require('../../lib/models/solid-host') + +describe('PasswordChangeRequest', () => { + sinon.spy(PasswordChangeRequest.prototype, 'error') + + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + let res = HttpMocks.createResponse() + + let accountManager = {} + let userStore = {} + + let options = { + accountManager, + userStore, + returnToUrl: 'https://example.com/resource', + response: res, + token: '12345', + newPassword: 'swordfish' + } + + let request = new PasswordChangeRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(options.token) + expect(request.newPassword).to.equal(options.newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + let returnToUrl = 'https://example.com/resource' + let token = '12345' + let newPassword = 'swordfish' + let accountManager = {} + let userStore = {} + + let req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token }, + body: { newPassword } + } + let res = HttpMocks.createResponse() + + let request = PasswordChangeRequest.fromParams(req, res) + + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(token) + expect(request.newPassword).to.equal(newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('get()', () => { + let returnToUrl = 'https://example.com/resource' + let token = '12345' + let userStore = {} + let res = HttpMocks.createResponse() + sinon.spy(res, 'render') + + it('should create an instance and render a change password form', () => { + let accountManager = { + validateResetToken: sinon.stub().resolves(true) + } + let req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(accountManager.validateResetToken) + .to.have.been.called() + expect(res.render).to.have.been.calledWith('auth/change-password', + { returnToUrl, token, validToken: true }) + }) + }) + + it('should display an error message on an invalid token', () => { + let accountManager = { + validateResetToken: sinon.stub().throws() + } + let req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordChangeRequest, 'handlePost') + + let returnToUrl = 'https://example.com/resource' + let token = '12345' + let newPassword = 'swordfish' + let host = SolidHost.from({ serverUri: 'https://example.com' }) + let alice = { + webId: 'https://alice.example.com/#me' + } + let storedToken = { webId: alice.webId } + let store = { + findUser: sinon.stub().resolves(alice), + updatePassword: sinon.stub() + } + let accountManager = { + host, + store, + userAccountFrom: sinon.stub().resolves(alice), + validateResetToken: sinon.stub().resolves(storedToken) + } + + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + let req = { + app: { locals: { accountManager, oidc: { users: store } } }, + query: { returnToUrl }, + body: { token, newPassword } + } + let res = HttpMocks.createResponse() + + return PasswordChangeRequest.post(req, res) + .then(() => { + expect(PasswordChangeRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('handlePost()', () => { + it('should display error message if validation error encountered', () => { + let returnToUrl = 'https://example.com/resource' + let token = '12345' + let userStore = {} + let res = HttpMocks.createResponse() + let accountManager = { + validateResetToken: sinon.stub().throws() + } + let req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + let request = PasswordChangeRequest.fromParams(req, res) + + return PasswordChangeRequest.handlePost(request) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('validateToken()', () => { + it('should return false if no token is present', () => { + let accountManager = { + validateResetToken: sinon.stub() + } + let request = new PasswordChangeRequest({ accountManager, token: null }) + + return request.validateToken() + .then(result => { + expect(result).to.be.false() + expect(accountManager.validateResetToken).to.not.have.been.called() + }) + }) + }) + + describe('validatePost()', () => { + it('should throw an error if no new password was entered', () => { + let request = new PasswordChangeRequest({ newPassword: null }) + + expect(() => request.validatePost()).to.throw('Please enter a new password') + }) + }) + + describe('error()', () => { + it('should invoke renderForm() with the error', () => { + let request = new PasswordChangeRequest({}) + request.renderForm = sinon.stub() + let error = new Error('error message') + + request.error(error) + + expect(request.renderForm).to.have.been.calledWith(error) + }) + }) + + describe('changePassword()', () => { + it('should create a new user store entry if none exists', () => { + // this would be the case for legacy pre-user-store accounts + let webId = 'https://alice.example.com/#me' + let user = { webId, id: webId } + let accountManager = { + userAccountFrom: sinon.stub().returns(user) + } + let userStore = { + findUser: sinon.stub().resolves(null), // no user found + createUser: sinon.stub().resolves(), + updatePassword: sinon.stub().resolves() + } + + let options = { + accountManager, userStore, newPassword: 'swordfish' + } + let request = new PasswordChangeRequest(options) + + return request.changePassword(user) + .then(() => { + expect(userStore.createUser).to.have.been.calledWith(user, options.newPassword) + }) + }) + }) + + describe('renderForm()', () => { + it('should set response status to error status, if error exists', () => { + let returnToUrl = 'https://example.com/resource' + let token = '12345' + let response = HttpMocks.createResponse() + sinon.spy(response, 'render') + + let options = { returnToUrl, token, response } + + let request = new PasswordChangeRequest(options) + + let error = new Error('error message') + + request.renderForm(error) + + expect(response.render).to.have.been.calledWith('auth/change-password', + { validToken: false, token, returnToUrl, error: 'error message' }) + expect(response.statusCode).to.equal(400) + }) + }) +}) diff --git a/test/unit/password-reset-request.js b/test/unit/password-reset-email-request.js similarity index 100% rename from test/unit/password-reset-request.js rename to test/unit/password-reset-email-request.js From e2d68fefcc74e874aace941b7336639c25acc6e7 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 1 May 2017 10:52:16 -0400 Subject: [PATCH 048/178] Add test for fullUrlForReq() --- lib/utils.js | 19 +++++++++++++++++-- test/unit/utils.js | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/utils.js b/lib/utils.js index ee56928a3..c28fb10ab 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -22,14 +22,29 @@ var $rdf = require('rdflib') var from = require('from2') var url = require('url') +/** + * Returns a fully qualified URL from an Express.js Request object. + * (It's insane that Express does not provide this natively.) + * + * Usage: + * + * ``` + * console.log(util.fullUrlForReq(req)) + * // -> https://example.com/path/to/resource?q1=v1 + * ``` + * + * @param req {IncomingRequest} + * + * @return {string} + */ function fullUrlForReq (req) { let fullUrl = url.format({ protocol: req.protocol, host: req.get('host'), - // pathname: req.originalUrl - pathname: req.path, + pathname: url.resolve(req.baseUrl, req.path), query: req.query }) + return fullUrl } diff --git a/test/unit/utils.js b/test/unit/utils.js index a17c39e59..c76a99b6e 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -49,4 +49,18 @@ describe('Utility functions', function () { assert.equal(utils.stripLineEndings(str), '123456') }) }) + + describe('fullUrlForReq()', () => { + it('should extract a fully-qualified url from an Express request', () => { + let req = { + protocol: 'https:', + get: (host) => 'example.com', + baseUrl: '/', + path: '/resource1', + query: { sort: 'desc' } + } + + assert.equal(utils.fullUrlForReq(req), 'https://example.com/resource1?sort=desc') + }) + }) }) From fc0a7b35f98a7a1e5b67ff72b82b41e79e7db651 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 1 May 2017 11:25:46 -0400 Subject: [PATCH 049/178] Add auth-related docstrings --- lib/requests/auth-request.js | 18 ++++++ lib/requests/login-request.js | 28 +++++++++ lib/requests/password-change-request.js | 65 ++++++++++++++++++++ lib/requests/password-reset-email-request.js | 54 ++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js index e59b8780b..4558ffd89 100644 --- a/lib/requests/auth-request.js +++ b/lib/requests/auth-request.js @@ -1,6 +1,24 @@ 'use strict' +/** + * Base authentication request (used for login and password reset workflows). + */ class AuthRequest { + /** + * Extracts a given parameter from the request - either from a GET query param, + * a POST body param, or an express registered `/:param`. + * Usage: + * + * ``` + * AuthRequest.parseParameter(req, 'client_id') + * // -> 'client123' + * ``` + * + * @param req {IncomingRequest} + * @param parameter {string} Parameter key + * + * @return {string|null} + */ static parseParameter (req, parameter) { let query = req.query || {} let body = req.body || {} diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index e1373db7e..b38178ffc 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -167,6 +167,12 @@ class LoginByPasswordRequest { return extracted } + /** + * Renders the login form along with the provided error. + * Serves as an error handler for this request workflow. + * + * @param error {Error} + */ error (error) { let res = this.response let params = Object.assign({}, this.authQueryParams, {error: error.message}) @@ -176,6 +182,9 @@ class LoginByPasswordRequest { res.render('auth/login', params) } + /** + * Renders the login form + */ renderView () { let res = this.response let params = Object.assign({}, this.authQueryParams, @@ -287,6 +296,15 @@ class LoginByPasswordRequest { return url.format(authUrl) } + /** + * Returns a URL to redirect the user to after login. + * Either uses the provided `redirect_uri` auth query param, or simply + * returns the user profile URI if none was provided. + * + * @param validUser {UserAccount} + * + * @return {string} + */ postLoginUrl (validUser) { let uri @@ -301,6 +319,16 @@ class LoginByPasswordRequest { return uri } + /** + * Returns a URL to redirect the user to after registration (used for the + * 'No account? Register for a new one' link on the /login page). + * Either uses the provided `redirect_uri` auth query param, or just uses + * the server uri. + * + * @param validUser {UserAccount} + * + * @return {string} + */ postRegisterUrl () { let uri diff --git a/lib/requests/password-change-request.js b/lib/requests/password-change-request.js index 2c82a82eb..081c8a138 100644 --- a/lib/requests/password-change-request.js +++ b/lib/requests/password-change-request.js @@ -28,6 +28,15 @@ class PasswordChangeRequest extends AuthRequest { this.newPassword = options.newPassword } + /** + * Factory method, returns an initialized instance of PasswordChangeRequest + * from an incoming http request. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * + * @return {PasswordChangeRequest} + */ static fromParams (req, res) { let locals = req.app.locals let accountManager = locals.accountManager @@ -56,6 +65,8 @@ class PasswordChangeRequest extends AuthRequest { * * @param req {IncomingRequest} * @param res {ServerResponse} + * + * @return {Promise} */ static get (req, res) { const request = PasswordChangeRequest.fromParams(req, res) @@ -66,12 +77,29 @@ class PasswordChangeRequest extends AuthRequest { .catch(error => request.error(error)) } + /** + * Handles a Change Password POST request on behalf of a middleware handler. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * + * @return {Promise} + */ static post (req, res) { const request = PasswordChangeRequest.fromParams(req, res) return PasswordChangeRequest.handlePost(request) } + /** + * Performs the 'Change Password' operation, after the user submits the + * password change form. Validates the parameters (the one-time token, + * the new password), changes the password, and renders the success view. + * + * @param request {PasswordChangeRequest} + * + * @return {Promise} + */ static handlePost (request) { return Promise.resolve() .then(() => request.validatePost()) @@ -81,12 +109,26 @@ class PasswordChangeRequest extends AuthRequest { .catch(error => request.error(error)) } + /** + * Validates the 'Change Password' parameters, and throws an error if any + * validation fails. + * + * @throws {Error} + */ validatePost () { if (!this.newPassword) { throw new Error('Please enter a new password') } } + /** + * Validates the one-time Password Reset token that was emailed to the user. + * If the token service has a valid token saved for the given key, it returns + * the token object value (which contains the user's WebID URI, etc). + * If no token is saved, returns `false`. + * + * @return {Promise} + */ validateToken () { return Promise.resolve() .then(() => { @@ -107,6 +149,15 @@ class PasswordChangeRequest extends AuthRequest { }) } + /** + * Changes the password that's saved in the user store. + * If the user has no user store entry, it creates one. + * + * @param tokenContents {Object} + * @param tokenContents.webId {string} + * + * @return {Promise} + */ changePassword (tokenContents) { let user = this.accountManager.userAccountFrom(tokenContents) @@ -122,10 +173,21 @@ class PasswordChangeRequest extends AuthRequest { }) } + /** + * Renders the 'change password' form along with the provided error. + * Serves as an error handler for this request workflow. + * + * @param error {Error} + */ error (error) { this.renderForm(error) } + /** + * Renders the 'change password' form. + * + * @param [error] {Error} Optional error to display + */ renderForm (error) { let params = { validToken: this.validToken, @@ -141,6 +203,9 @@ class PasswordChangeRequest extends AuthRequest { this.response.render('auth/change-password', params) } + /** + * Displays the 'password has been changed' success view. + */ renderSuccess () { this.response.render('auth/password-changed', { returnToUrl: this.returnToUrl }) } diff --git a/lib/requests/password-reset-email-request.js b/lib/requests/password-reset-email-request.js index 0a2fc5e46..315482dd3 100644 --- a/lib/requests/password-reset-email-request.js +++ b/lib/requests/password-reset-email-request.js @@ -20,6 +20,15 @@ class PasswordResetEmailRequest extends AuthRequest { this.username = options.username } + /** + * Factory method, returns an initialized instance of PasswordResetEmailRequest + * from an incoming http request. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * + * @return {PasswordResetEmailRequest} + */ static fromParams (req, res) { let locals = req.app.locals let accountManager = locals.accountManager @@ -54,6 +63,17 @@ class PasswordResetEmailRequest extends AuthRequest { request.renderForm() } + /** + * Handles a Reset Password POST request on behalf of a middleware handler. + * Usage: + * + * ``` + * app.get('/password/reset', PasswordResetEmailRequest.get) + * ``` + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + */ static post (req, res) { const request = PasswordResetEmailRequest.fromParams(req, res) @@ -62,6 +82,14 @@ class PasswordResetEmailRequest extends AuthRequest { return PasswordResetEmailRequest.handlePost(request) } + /** + * Performs a 'send me a password reset email' request operation, after the + * user has entered an email into the reset form. + * + * @param request {IncomingRequest} + * + * @return {Promise} + */ static handlePost (request) { return Promise.resolve() .then(() => request.validate()) @@ -71,6 +99,12 @@ class PasswordResetEmailRequest extends AuthRequest { .catch(error => request.error(error)) } + /** + * Validates the request parameters, and throws an error if any + * validation fails. + * + * @throws {Error} + */ validate () { if (this.accountManager.multiUser && !this.username) { throw new Error('Username required') @@ -99,6 +133,14 @@ class PasswordResetEmailRequest extends AuthRequest { }) } + /** + * Loads the account recovery email for a given user and sends out a + * password request email. + * + * @param userAccount {UserAccount} + * + * @return {Promise} + */ sendResetLink (userAccount) { let accountManager = this.accountManager @@ -113,6 +155,12 @@ class PasswordResetEmailRequest extends AuthRequest { }) } + /** + * Renders the 'send password reset link' form along with the provided error. + * Serves as an error handler for this request workflow. + * + * @param error {Error} + */ error (error) { let res = this.response @@ -129,6 +177,9 @@ class PasswordResetEmailRequest extends AuthRequest { res.render('auth/reset-password', params) } + /** + * Renders the 'send password reset link' form + */ renderForm () { let params = { returnToUrl: this.returnToUrl, @@ -138,6 +189,9 @@ class PasswordResetEmailRequest extends AuthRequest { this.response.render('auth/reset-password', params) } + /** + * Displays the 'your reset link has been sent' success message view + */ renderSuccess () { this.response.render('auth/reset-link-sent') } From 97dac718ef62c4fd4a86bf9e39eae53804e6bce3 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 1 May 2017 11:45:35 -0400 Subject: [PATCH 050/178] Add a fix for utils.debrack() and unit tests From PR #475. --- lib/utils.js | 11 ++++++++++- test/unit/utils.js | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/utils.js b/lib/utils.js index c28fb10ab..d9f559141 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -48,8 +48,17 @@ function fullUrlForReq (req) { return fullUrl } +/** + * Removes the `<` and `>` brackets around a string and returns it. + * Used by the `allow` handler in `verifyDelegator()` logic. + * @method debrack + * + * @param s {string} + * + * @return {string} + */ function debrack (s) { - if (s.length < 2) { + if (!s || s.length < 2) { return s } if (s[0] !== '<') { diff --git a/test/unit/utils.js b/test/unit/utils.js index c76a99b6e..7d5d3fc96 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -50,6 +50,25 @@ describe('Utility functions', function () { }) }) + describe('debrack()', () => { + it('should return null if no string is passed', () => { + assert.equal(utils.debrack(), null) + }) + + it('should return the string if no brackets are present', () => { + assert.equal(utils.debrack('test string'), 'test string') + }) + + it('should return the string if less than 2 chars long', () => { + assert.equal(utils.debrack(''), '') + assert.equal(utils.debrack('<'), '<') + }) + + it('should remove brackets if wrapping the string', () => { + assert.equal(utils.debrack(''), 'test string') + }) + }) + describe('fullUrlForReq()', () => { it('should extract a fully-qualified url from an Express request', () => { let req = { From e79f02514c575ed785f2e737f8d9b8dc6eda989e Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 4 May 2017 12:02:03 -0400 Subject: [PATCH 051/178] Set User: response header if authenticated (for legacy compat) --- lib/api/authn/index.js | 14 +++++++++++++- lib/api/authn/webid-oidc.js | 4 +++- lib/create-app.js | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index 87dd81118..8b5b8950a 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -16,7 +16,19 @@ function overrideWith (forceUserId) { } } +/** + * Sets the `User:` response header if the user has been authenticated. + */ +function setUserHeader (req, res, next) { + let session = req.session + let webId = session.identified && session.userId + + res.set('User', webId || '') + next() +} + module.exports = { oidc: require('./webid-oidc'), - overrideWith + overrideWith, + setUserHeader } diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index d3eebfe75..6756744df 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -64,4 +64,6 @@ function middleware (oidc) { return router } -module.exports.middleware = middleware +module.exports = { + middleware +} diff --git a/lib/create-app.js b/lib/create-app.js index 7703bfb8f..81fe1b8cc 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -196,6 +196,8 @@ function initAuthentication (argv, app) { default: throw new TypeError('Unsupported authentication scheme') } + + app.use('/', API.authn.setUserHeader) } /** From 9e71eb9655742b510c3cd5cd2735dfd3c05b828f Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 11 May 2017 12:11:14 -0400 Subject: [PATCH 052/178] Bump oidc-auth-manager dep to 0.7.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fd95f2470..2eb848c76 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.6.0", + "oidc-auth-manager": "^0.7.2", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", From b5f3dc48d213121f6361f02ea192c6f946704e51 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 18 May 2017 15:33:35 -0400 Subject: [PATCH 053/178] Re-add WebID-TLS auth code --- lib/api/authn/index.js | 1 + lib/api/authn/webid-tls.js | 45 + lib/api/index.js | 1 + lib/create-app.js | 16 +- lib/create-server.js | 4 + lib/requests/create-account-request.js | 111 ++ test/integration/account-creation-tls.js | 227 +++++ test/integration/acl-tls.js | 955 ++++++++++++++++++ test/resources/acl-tls/append-acl/abc.ttl | 1 + test/resources/acl-tls/append-acl/abc.ttl.acl | 8 + test/resources/acl-tls/append-acl/abc2.ttl | 1 + .../resources/acl-tls/append-acl/abc2.ttl.acl | 8 + test/resources/acl-tls/append-inherited/.acl | 13 + test/resources/acl-tls/empty-acl/.acl | 0 test/resources/acl-tls/fake-account/.acl | 5 + .../resources/acl-tls/fake-account/hello.html | 9 + test/resources/acl-tls/no-acl/test-file.html | 1 + test/resources/acl-tls/origin/.acl | 5 + test/resources/acl-tls/owner-only/.acl | 5 + test/resources/acl-tls/read-acl/.acl | 10 + test/resources/acl-tls/write-acl/.acl | 5 + .../acl-tls/write-acl/empty-acl/.acl | 0 test/unit/create-account-request.js | 60 ++ 23 files changed, 1486 insertions(+), 5 deletions(-) create mode 100644 lib/api/authn/webid-tls.js create mode 100644 test/integration/account-creation-tls.js create mode 100644 test/integration/acl-tls.js create mode 100644 test/resources/acl-tls/append-acl/abc.ttl create mode 100644 test/resources/acl-tls/append-acl/abc.ttl.acl create mode 100644 test/resources/acl-tls/append-acl/abc2.ttl create mode 100644 test/resources/acl-tls/append-acl/abc2.ttl.acl create mode 100644 test/resources/acl-tls/append-inherited/.acl create mode 100644 test/resources/acl-tls/empty-acl/.acl create mode 100644 test/resources/acl-tls/fake-account/.acl create mode 100644 test/resources/acl-tls/fake-account/hello.html create mode 100644 test/resources/acl-tls/no-acl/test-file.html create mode 100644 test/resources/acl-tls/origin/.acl create mode 100644 test/resources/acl-tls/owner-only/.acl create mode 100644 test/resources/acl-tls/read-acl/.acl create mode 100644 test/resources/acl-tls/write-acl/.acl create mode 100644 test/resources/acl-tls/write-acl/empty-acl/.acl diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index 8b5b8950a..9caf979ea 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -29,6 +29,7 @@ function setUserHeader (req, res, next) { module.exports = { oidc: require('./webid-oidc'), + tls: require('./webid-tls'), overrideWith, setUserHeader } diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js new file mode 100644 index 000000000..4ccabc2fa --- /dev/null +++ b/lib/api/authn/webid-tls.js @@ -0,0 +1,45 @@ +module.exports = handler +module.exports.authenticate = authenticate + +var webid = require('webid/tls') +var debug = require('../../debug').authentication + +function authenticate () { + return handler +} + +function handler (req, res, next) { + // User already logged in? skip + if (req.session.userId && req.session.identified) { + debug('User: ' + req.session.userId) + res.set('User', req.session.userId) + return next() + } + + var certificate = req.connection.getPeerCertificate() + // Certificate is empty? skip + if (certificate === null || Object.keys(certificate).length === 0) { + debug('No client certificate found in the request. Did the user click on a cert?') + setEmptySession(req) + return next() + } + + // Verify webid + webid.verify(certificate, function (err, result) { + if (err) { + debug('Error processing certificate: ' + err.message) + setEmptySession(req) + return next() + } + req.session.userId = result + req.session.identified = true + debug('Identified user: ' + req.session.userId) + res.set('User', req.session.userId) + return next() + }) +} + +function setEmptySession (req) { + req.session.userId = '' + req.session.identified = false +} diff --git a/lib/api/index.js b/lib/api/index.js index 907a32be2..5ce7a3514 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -3,5 +3,6 @@ module.exports = { authn: require('./authn'), oidc: require('./authn/webid-oidc'), + tls: require('./authn/webid-tls'), accounts: require('./accounts/user-accounts') } diff --git a/lib/create-app.js b/lib/create-app.js index 81fe1b8cc..970c9c409 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -174,7 +174,16 @@ function initWebId (argv, app, ldp) { function initAuthentication (argv, app) { let authMethod = argv.auth + if (argv.forceUser) { + app.use('/', API.authn.overrideWith(argv.forceUser)) + return + } + switch (authMethod) { + case 'tls': + // Enforce authentication with WebID-TLS on all LDP routes + app.use('/', API.tls.authenticate()) + break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) app.locals.oidc = oidc @@ -189,15 +198,12 @@ function initAuthentication (argv, app) { // Enforce authentication with WebID-OIDC on all LDP routes app.use('/', oidc.rs.authenticate()) - if (argv.forceUser) { - app.use('/', API.authn.overrideWith(argv.forceUser)) - } + app.use('/', API.authn.setUserHeader) + break default: throw new TypeError('Unsupported authentication scheme') } - - app.use('/', API.authn.setUserHeader) } /** diff --git a/lib/create-server.js b/lib/create-server.js index 878687f19..6e4c0b225 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -58,6 +58,10 @@ function createServer (argv, app) { cert: cert } + if (ldp.webid && ldp.auth === 'tls') { + credentials.requestCert = true + } + server = https.createServer(credentials, app) } diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index 607cd03e0..fbe2241ac 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -1,6 +1,7 @@ 'use strict' const AuthRequest = require('./auth-request') +const WebIdTlsCertificate = require('../models/webid-tls-certificate') const debug = require('../debug').accounts /** @@ -72,6 +73,9 @@ class CreateAccountRequest extends AuthRequest { options.password = req.body.password options.userStore = locals.oidc.users return new CreateOidcAccountRequest(options) + case 'tls': + options.spkac = req.body.spkac + return new CreateTlsAccountRequest(options) default: throw new TypeError('Unsupported authentication scheme') } @@ -255,5 +259,112 @@ class CreateOidcAccountRequest extends CreateAccountRequest { } } +/** + * Models a Create Account request for a server using WebID-TLS as primary + * authentication mode. Handles generating and saving a TLS certificate, etc. + * + * @class CreateTlsAccountRequest + * @extends CreateAccountRequest + */ +class CreateTlsAccountRequest extends CreateAccountRequest { + /** + * @constructor + * + * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring + * @param [options.spkac] {string} + */ + constructor (options = {}) { + super(options) + this.spkac = options.spkac + this.certificate = null + } + + /** + * Generates a new X.509v3 RSA certificate (if `spkac` was passed in) and + * adds it to the user account. Used for storage in an agent's WebID + * Profile, for WebID-TLS authentication. + * + * @param userAccount {UserAccount} + * @param userAccount.webId {string} An agent's WebID URI + * + * @throws {Error} HTTP 400 error if errors were encountering during + * certificate generation. + * + * @return {Promise} Chainable + */ + generateTlsCertificate (userAccount) { + if (!this.spkac) { + debug('Missing spkac param, not generating cert during account creation') + return Promise.resolve(userAccount) + } + + return Promise.resolve() + .then(() => { + let host = this.accountManager.host + return WebIdTlsCertificate.fromSpkacPost(this.spkac, userAccount, host) + .generateCertificate() + }) + .catch(err => { + err.status = 400 + err.message = 'Error generating a certificate: ' + err.message + throw err + }) + .then(certificate => { + debug('Generated a WebID-TLS certificate as part of account creation') + this.certificate = certificate + return userAccount + }) + } + + /** + * Generates a WebID-TLS certificate and saves it to the user's profile + * graph. + * + * @param userAccount {UserAccount} + * + * @return {Promise} Chainable + */ + saveCredentialsFor (userAccount) { + return this.generateTlsCertificate(userAccount) + .then(userAccount => { + if (this.certificate) { + return this.accountManager + .addCertKeyToProfile(this.certificate, userAccount) + .then(() => { + debug('Saved generated WebID-TLS certificate to profile') + }) + } else { + debug('No certificate generated, no need to save to profile') + } + }) + .then(() => { + return userAccount + }) + } + + /** + * Writes the generated TLS certificate to the http Response object. + * + * @param userAccount {UserAccount} + * + * @return {UserAccount} Chainable + */ + sendResponse (userAccount) { + let res = this.response + res.set('User', userAccount.webId) + res.status(200) + + if (this.certificate) { + res.set('Content-Type', 'application/x-x509-user-cert') + res.send(this.certificate.toDER()) + } else { + res.end() + } + + return userAccount + } +} + module.exports = CreateAccountRequest module.exports.CreateAccountRequest = CreateAccountRequest +module.exports.CreateTlsAccountRequest = CreateTlsAccountRequest diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls.js new file mode 100644 index 000000000..295c0fb9d --- /dev/null +++ b/test/integration/account-creation-tls.js @@ -0,0 +1,227 @@ +const supertest = require('supertest') +// Helper functions for the FS +const $rdf = require('rdflib') + +const { rm, read } = require('../test-utils') +const ldnode = require('../../index') +const fs = require('fs-extra') +const path = require('path') + +describe('AccountManager (account creation tests)', function () { + this.timeout(10000) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + var address = 'https://localhost:3457' + var host = 'localhost:3457' + var ldpHttpsServer + let rootPath = path.join(__dirname, '../resources/accounts/') + var ldp = ldnode.createServer({ + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'tls', + webid: true, + idp: true, + strictOrigin: true + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(3457, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'localhost/index.html')) + fs.removeSync(path.join(rootPath, 'localhost/index.html.acl')) + }) + + var server = supertest(address) + + it('should expect a 404 on GET /accounts', function (done) { + server.get('/api/accounts') + .expect(404, done) + }) + + describe('accessing accounts', function () { + it('should be able to access public file of an account', function (done) { + var subdomain = supertest('https://tim.' + host) + subdomain.get('/hello.html') + .expect(200, done) + }) + it('should get 404 if root does not exist', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.get('/') + .set('Accept', 'text/turtle') + .set('Origin', 'http://example.com') + .expect(404) + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect('Access-Control-Allow-Credentials', 'true') + .end(function (err, res) { + done(err) + }) + }) + }) + + describe('generating a certificate', () => { + beforeEach(function () { + rm('accounts/nicola.localhost') + }) + after(function () { + rm('accounts/nicola.localhost') + }) + + it('should generate a certificate if spkac is valid', (done) => { + var spkac = read('example_spkac.cnf') + var subdomain = supertest.agent('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&spkac=' + spkac) + .expect('Content-Type', /application\/x-x509-user-cert/) + .expect(200, done) + }) + + it('should not generate a certificate if spkac is not valid', (done) => { + var subdomain = supertest('https://nicola.' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola') + .expect(200) + .end((err) => { + if (err) return done(err) + + subdomain.post('/api/accounts/cert') + .send('username=nicola&spkac=') + .expect(400, done) + }) + }) + }) + + describe('creating an account with POST', function () { + beforeEach(function () { + rm('accounts/nicola.localhost') + }) + + after(function () { + rm('accounts/nicola.localhost') + }) + + it('should not create WebID if no username is given', (done) => { + let subdomain = supertest('https://nicola.' + host) + let spkac = read('example_spkac.cnf') + subdomain.post('/api/accounts/new') + .send('username=&spkac=' + spkac) + .expect(400, done) + }) + + it('should not create a WebID if it already exists', function (done) { + var subdomain = supertest('https://nicola.' + host) + let spkac = read('example_spkac.cnf') + subdomain.post('/api/accounts/new') + .send('username=nicola&spkac=' + spkac) + .expect(200) + .end((err) => { + if (err) { + return done(err) + } + subdomain.post('/api/accounts/new') + .send('username=nicola&spkac=' + spkac) + .expect(400) + .end((err) => { + done(err) + }) + }) + }) + + it('should create the default folders', function (done) { + var subdomain = supertest('https://nicola.' + host) + let spkac = read('example_spkac.cnf') + subdomain.post('/api/accounts/new') + .send('username=nicola&spkac=' + spkac) + .expect(200) + .end(function (err) { + if (err) { + return done(err) + } + var domain = host.split(':')[0] + var card = read(path.join('accounts/nicola.' + domain, + 'profile/card')) + var cardAcl = read(path.join('accounts/nicola.' + domain, + 'profile/card.acl')) + var prefs = read(path.join('accounts/nicola.' + domain, + 'settings/prefs.ttl')) + var inboxAcl = read(path.join('accounts/nicola.' + domain, + 'inbox/.acl')) + var rootMeta = read(path.join('accounts/nicola.' + domain, '.meta')) + var rootMetaAcl = read(path.join('accounts/nicola.' + domain, + '.meta.acl')) + + if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && + rootMetaAcl) { + done() + } else { + done(new Error('failed to create default files')) + } + }) + }) + + it('should link WebID to the root account', function (done) { + var subdomain = supertest('https://nicola.' + host) + let spkac = read('example_spkac.cnf') + subdomain.post('/api/accounts/new') + .send('username=nicola&spkac=' + spkac) + .expect(200) + .end(function (err) { + if (err) { + return done(err) + } + subdomain.get('/.meta') + .expect(200) + .end(function (err, data) { + if (err) { + return done(err) + } + var graph = $rdf.graph() + $rdf.parse( + data.text, + graph, + 'https://nicola.' + host + '/.meta', + 'text/turtle') + var statements = graph.statementsMatching( + undefined, + $rdf.sym('http://www.w3.org/ns/solid/terms#account'), + undefined) + if (statements.length === 1) { + done() + } else { + done(new Error('missing link to WebID of account')) + } + }) + }) + }) + + it('should create a private settings container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/settings/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private prefs file in the settings container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/prefs.ttl') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private inbox container', function (done) { + var subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + }) +}) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js new file mode 100644 index 000000000..26f5a2167 --- /dev/null +++ b/test/integration/acl-tls.js @@ -0,0 +1,955 @@ +var assert = require('chai').assert +var fs = require('fs-extra') +var $rdf = require('rdflib') +var request = require('request') +var path = require('path') + +/** + * Note: this test suite requires an internet connection, since it actually + * uses remote accounts https://user1.databox.me and https://user2.databox.me + */ + +// Helper functions for the FS +var rm = require('../test-utils').rm +// var write = require('./test-utils').write +// var cp = require('./test-utils').cp +// var read = require('./test-utils').read + +var ldnode = require('../../index') +var ns = require('solid-namespace')($rdf) + +describe('ACL HTTP', function () { + this.timeout(10000) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + var address = 'https://localhost:3456/test/' + let rootPath = path.join(__dirname, '../resources') + var ldpHttpsServer + var ldp = ldnode.createServer({ + mount: '/test', + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + strictOrigin: true, + auth: 'tls' + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(3456, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'index.html')) + fs.removeSync(path.join(rootPath, 'index.html.acl')) + }) + + var aclExtension = '.acl' + var metaExtension = '.meta' + + var testDir = 'acl-tls/testDir' + var testDirAclFile = testDir + '/' + aclExtension + var testDirMetaFile = testDir + '/' + metaExtension + + var abcFile = testDir + '/abc.ttl' + var abcAclFile = abcFile + aclExtension + + var globFile = testDir + '/*' + + var groupFile = testDir + '/group' + + var origin1 = 'http://example.org/' + var origin2 = 'http://example.com/' + + var user1 = 'https://user1.databox.me/profile/card#me' + var user2 = 'https://user2.databox.me/profile/card#me' + var userCredentials = { + user1: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) + }, + user2: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) + } + } + + function createOptions (path, user) { + var options = { + url: address + path, + headers: { + accept: 'text/turtle' + } + } + if (user) { + options.agentOptions = userCredentials[user] + } + return options + } + + describe('no ACL', function () { + it('should return 403 for any resource', function (done) { + var options = createOptions('/acl-tls/no-acl/', 'user1') + request(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should have `User` set in the Response Header', function (done) { + var options = createOptions('/acl-tls/no-acl/', 'user1') + request(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) + + describe('empty .acl', function () { + describe('with no defaultForNew in parent path', function () { + it('should give no access', function (done) { + var options = createOptions('/acl-tls/empty-acl/test-folder', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not let edit the .acl', function (done) { + var options = createOptions('/acl-tls/empty-acl/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not let read the .acl', function (done) { + var options = createOptions('/acl-tls/empty-acl/.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) + describe('with defaultForNew in parent path', function () { + before(function () { + rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') + rm('/acl-tls/write-acl/empty-acl/test-file') + rm('/acl-tls/write-acl/test-file') + rm('/acl-tls/write-acl/test-file.acl') + }) + + it('should fail to create a container', function (done) { + var options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 409) + done() + }) + }) + it('should allow creation of new files', function (done) { + var options = createOptions('/acl-tls/write-acl/empty-acl/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('should allow creation of new files in deeper paths', function (done) { + var options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('Should create empty acl file', function (done) { + var options = createOptions('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('should return text/turtle for the acl file', function (done) { + var options = createOptions('/acl-tls/write-acl/.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + assert.match(response.headers['content-type'], /text\/turtle/) + done() + }) + }) + it('should create test file', function (done) { + var options = createOptions('/acl-tls/write-acl/test-file', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("should create test file's acl file", function (done) { + var options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("should access test file's acl file", function (done) { + var options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + assert.match(response.headers['content-type'], /text\/turtle/) + done() + }) + }) + + after(function () { + rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') + rm('/acl-tls/write-acl/empty-acl/test-file') + rm('/acl-tls/write-acl/test-file') + rm('/acl-tls/write-acl/test-file.acl') + }) + }) + }) + + describe('Origin', function () { + before(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + + it('should PUT new ACL file', function (done) { + var options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '<#Owner> a ;\n' + + ' ;\n' + + ' <' + user1 + '>;\n' + + ' <' + origin1 + '>;\n' + + ' , , .\n' + + '<#Public> a ;\n' + + ' <./>;\n' + + ' ;\n' + + ' <' + origin1 + '>;\n' + + ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + // TODO triple header + // TODO user header + }) + }) + it('user1 should be able to access test directory', function (done) { + var options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access to test directory when origin is valid', + function (done) { + var options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be denied access to test directory when origin is invalid', + function (done) { + var options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should be able to access test directory', function (done) { + var options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should be able to access to test directory when origin is valid', + function (done) { + var options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should be denied access to test directory when origin is invalid', + function (done) { + var options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + + after(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + }) + + describe('Read-only', function () { + var body = fs.readFileSync(path.join(__dirname, '../resources/acl-tls/read-acl/.acl')) + it('user1 should be able to access ACL file', function (done) { + var options = createOptions('/acl-tls/read-acl/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test directory', function (done) { + var options = createOptions('/acl-tls/read-acl/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify ACL file', function (done) { + var options = createOptions('/acl-tls/read-acl/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should be able to access test directory', function (done) { + var options = createOptions('/acl-tls/read-acl/', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to access ACL file', function (done) { + var options = createOptions('/acl-tls/read-acl/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should not be able to modify ACL file', function (done) { + var options = createOptions('/acl-tls/read-acl/.acl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should be able to access test direcotory', function (done) { + var options = createOptions('/acl-tls/read-acl/') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify ACL file', function (done) { + var options = createOptions('/acl-tls/read-acl/.acl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + }) + + describe.skip('Glob', function () { + it('user2 should be able to send glob request', function (done) { + var options = createOptions(globFile, 'user2') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + var globGraph = $rdf.graph() + $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') + var authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) + assert.equal(authz, null) + done() + }) + }) + it('user1 should be able to send glob request', function (done) { + var options = createOptions(globFile, 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + var globGraph = $rdf.graph() + $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') + var authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) + assert.equal(authz, null) + done() + }) + }) + it('user1 should be able to delete ACL file', function (done) { + var options = createOptions(testDirAclFile, 'user1') + request.del(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + }) + + describe('Append-only', function () { + // var body = fs.readFileSync(__dirname + '/resources/acl-tls/append-acl/abc.ttl.acl') + it("user1 should be able to access test file's ACL file", function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user1') + request.head(options, function (error, response) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to PATCH a resource', function (done) { + var options = createOptions('/acl-tls/append-inherited/test.ttl', 'user1') + options.headers = { + 'content-type': 'application/sparql-update' + } + options.body = 'INSERT DATA { :test :hello 456 .}' + request.patch(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + // TODO POST instead of PUT + it('user1 should be able to modify test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user2 should not be able to access test file's ACL file", function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should not be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 (with append permission) cannot use PUT to append', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent (with append permissions) should not PUT', function (done) { + var options = createOptions('/acl-tls/append-acl/abc.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + after(function () { + rm('acl-tls/append-inherited/test.ttl') + }) + }) + + describe('Restricted', function () { + var body = '<#Owner> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user1 + '>;\n' + + ' , , .\n' + + '<#Restricted> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user2 + '>;\n' + + ' , .\n' + it("user1 should be able to modify test file's ACL file", function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user1 should be able to access test file's ACL file", function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user2 should not be able to access test file's ACL file", function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should be able to modify test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent should not be able to modify test file', function (done) { + var options = createOptions('/acl-tls/append-acl/abc2.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + }) + + describe.skip('Group', function () { + var groupTriples = '<#> a ;\n' + + ' , , <' + user2 + '> .\n' + var body = '<#Owner>\n' + + ' <' + address + abcFile + '>, <' + + address + abcAclFile + '>;\n' + + ' <' + user1 + '>;\n' + + ' <' + address + testDir + '>;\n' + + ' , .\n' + + '<#Group>\n' + + ' <' + address + abcFile + '>;\n' + + ' <' + address + groupFile + '#>;\n' + + ' .\n' + it('user1 should be able to add group triples', function (done) { + var options = createOptions(groupFile, 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = groupTriples + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user1 should be able to modify test file's ACL file", function (done) { + var options = createOptions(abcAclFile, 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + + it("user1 should be able to access test file's ACL file", function (done) { + var options = createOptions(abcAclFile, 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify test file', function (done) { + var options = createOptions(abcFile, 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + var options = createOptions(abcFile, 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user2 should not be able to access test file's ACL file", function (done) { + var options = createOptions(abcAclFile, 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should be able to access test file', function (done) { + var options = createOptions(abcFile, 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to modify test file', function (done) { + var options = createOptions(abcFile, 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + var options = createOptions(abcFile) + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent should not be able to modify test file', function (done) { + var options = createOptions(abcFile) + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('user1 should be able to delete group file', function (done) { + var options = createOptions(groupFile, 'user1') + request.del(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user1 should be able to delete test file's ACL file", function (done) { + var options = createOptions(abcAclFile, 'user1') + request.del(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + }) + + describe('defaultForNew', function () { + before(function () { + rm('/acl-tls/write-acl/default-for-new/.acl') + rm('/acl-tls/write-acl/default-for-new/test-file.ttl') + }) + + var body = '<#Owner> a ;\n' + + ' <./>;\n' + + ' <' + user1 + '>;\n' + + ' <./>;\n' + + ' , , .\n' + + '<#Default> a ;\n' + + ' <./>;\n' + + ' <./>;\n' + + ' ;\n' + + ' .\n' + it("user1 should be able to modify test directory's ACL file", function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user1 should be able to access test direcotory's ACL file", function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to create new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to access new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user2 should not be able to access test direcotory's ACL file", function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should be able to access new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to modify new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should be able to access new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify new test file', function (done) { + var options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + + after(function () { + rm('/acl-tls/write-acl/default-for-new/.acl') + rm('/acl-tls/write-acl/default-for-new/test-file.ttl') + }) + }) + + describe('WebID delegation tests', function () { + it('user1 should be able delegate to user2', function (done) { + // var body = '<' + user1 + '> <' + user2 + '> .' + var options = { + url: user1, + headers: { + 'content-type': 'text/turtle' + }, + agentOptions: { + key: userCredentials.user1.key, + cert: userCredentials.user1.cert + } + } + request.post(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + // it("user2 should be able to make requests on behalf of user1", function(done) { + // var options = createOptions(abcdFile, 'user2') + // options.headers = { + // 'content-type': 'text/turtle', + // 'On-Behalf-Of': '<' + user1 + '>' + // } + // options.body = " ." + // request.post(options, function(error, response, body) { + // assert.equal(error, null) + // assert.equal(response.statusCode, 200) + // done() + // }) + // }) + }) + + describe.skip('Cleanup', function () { + it('should remove all files and dirs created', function (done) { + try { + // must remove the ACLs in sync + fs.unlinkSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/abcd.ttl')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/')) + fs.unlinkSync(path.join(__dirname, '../resources/' + abcFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirAclFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirMetaFile)) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir)) + fs.rmdirSync(path.join(__dirname, '../resources/acl-tls/')) + done() + } catch (e) { + done(e) + } + }) + }) +}) diff --git a/test/resources/acl-tls/append-acl/abc.ttl b/test/resources/acl-tls/append-acl/abc.ttl new file mode 100644 index 000000000..5296a5255 --- /dev/null +++ b/test/resources/acl-tls/append-acl/abc.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/acl-tls/append-acl/abc.ttl.acl b/test/resources/acl-tls/append-acl/abc.ttl.acl new file mode 100644 index 000000000..ab1b8dd09 --- /dev/null +++ b/test/resources/acl-tls/append-acl/abc.ttl.acl @@ -0,0 +1,8 @@ +<#Owner> a ; + <./abc.ttl>; + ; + , , . +<#AppendOnly> a ; + <./abc.ttl>; + ; + . diff --git a/test/resources/acl-tls/append-acl/abc2.ttl b/test/resources/acl-tls/append-acl/abc2.ttl new file mode 100644 index 000000000..07eff8ea5 --- /dev/null +++ b/test/resources/acl-tls/append-acl/abc2.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/acl-tls/append-acl/abc2.ttl.acl b/test/resources/acl-tls/append-acl/abc2.ttl.acl new file mode 100644 index 000000000..873a63908 --- /dev/null +++ b/test/resources/acl-tls/append-acl/abc2.ttl.acl @@ -0,0 +1,8 @@ +<#Owner> a ; + <./abc2.ttl>; + ; + , , . +<#Restricted> a ; + <./abc2.ttl>; + ; + , . diff --git a/test/resources/acl-tls/append-inherited/.acl b/test/resources/acl-tls/append-inherited/.acl new file mode 100644 index 000000000..9bfe07b39 --- /dev/null +++ b/test/resources/acl-tls/append-inherited/.acl @@ -0,0 +1,13 @@ +@prefix acl: . + +<#authorization1> + a acl:Authorization; + + acl:agent + ; + acl:accessTo <./>; + acl:mode + acl:Read, acl:Write, acl:Control; + + acl:defaultForNew <./>. + diff --git a/test/resources/acl-tls/empty-acl/.acl b/test/resources/acl-tls/empty-acl/.acl new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/acl-tls/fake-account/.acl b/test/resources/acl-tls/fake-account/.acl new file mode 100644 index 000000000..f49950774 --- /dev/null +++ b/test/resources/acl-tls/fake-account/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/acl-tls/fake-account/hello.html b/test/resources/acl-tls/fake-account/hello.html new file mode 100644 index 000000000..7fd820ca9 --- /dev/null +++ b/test/resources/acl-tls/fake-account/hello.html @@ -0,0 +1,9 @@ + + + + Hello + + +Hello + + \ No newline at end of file diff --git a/test/resources/acl-tls/no-acl/test-file.html b/test/resources/acl-tls/no-acl/test-file.html new file mode 100644 index 000000000..16b832e3f --- /dev/null +++ b/test/resources/acl-tls/no-acl/test-file.html @@ -0,0 +1 @@ +test-file.html \ No newline at end of file diff --git a/test/resources/acl-tls/origin/.acl b/test/resources/acl-tls/origin/.acl new file mode 100644 index 000000000..52aa6cc78 --- /dev/null +++ b/test/resources/acl-tls/origin/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/acl-tls/owner-only/.acl b/test/resources/acl-tls/owner-only/.acl new file mode 100644 index 000000000..52aa6cc78 --- /dev/null +++ b/test/resources/acl-tls/owner-only/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/acl-tls/read-acl/.acl b/test/resources/acl-tls/read-acl/.acl new file mode 100644 index 000000000..c1ff5f067 --- /dev/null +++ b/test/resources/acl-tls/read-acl/.acl @@ -0,0 +1,10 @@ +<#Owner> + a ; + <./>; + ; + , , . +<#Public> + a ; + <./>; + ; + . \ No newline at end of file diff --git a/test/resources/acl-tls/write-acl/.acl b/test/resources/acl-tls/write-acl/.acl new file mode 100644 index 000000000..52aa6cc78 --- /dev/null +++ b/test/resources/acl-tls/write-acl/.acl @@ -0,0 +1,5 @@ +<#0> + a ; + <./> ; + ; + , . diff --git a/test/resources/acl-tls/write-acl/empty-acl/.acl b/test/resources/acl-tls/write-acl/empty-acl/.acl new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index b41a76b35..dcefcd804 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -46,6 +46,15 @@ describe('CreateAccountRequest', () => { it('should create subclass depending on authMethod', () => { let request, aliceData, req + aliceData = { username: 'alice' } + req = HttpMocks.createRequest({ + app: { locals: { accountManager } }, body: aliceData, session + }) + req.app.locals.authMethod = 'tls' + + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.respondTo('generateTlsCertificate') + aliceData = { username: 'alice', password: '12345' } req = HttpMocks.createRequest({ app: { locals: { accountManager, oidc: {} } }, body: aliceData, session @@ -172,3 +181,54 @@ describe('CreateOidcAccountRequest', () => { }) }) }) + +describe('CreateTlsAccountRequest', () => { + let authMethod = 'tls' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + let accountManager = AccountManager.from({ host, store }) + let aliceData = { username: 'alice' } + let req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + let request = CreateAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.spkac).to.equal(aliceData.spkac) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should call generateTlsCertificate()', () => { + let accountManager = AccountManager.from({ host, store }) + let aliceData = { username: 'alice' } + let req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + let request = CreateAccountRequest.fromParams(req, res) + let userAccount = accountManager.userAccountFrom(aliceData) + + let generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(generateTlsCertificate).to.have.been.calledWith(userAccount) + }) + }) + }) +}) From 0786e087eb4750c6fa2e4952aaf3c0fc943e229c Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 1 May 2017 09:49:14 -0400 Subject: [PATCH 054/178] Add a Login with Certificate button to login screen --- default-views/auth/login.hbs | 84 ++++++++++-------------- default-views/auth/username-password.hbs | 29 ++++++++ 2 files changed, 62 insertions(+), 51 deletions(-) create mode 100644 default-views/auth/username-password.hbs diff --git a/default-views/auth/login.hbs b/default-views/auth/login.hbs index b9247fdde..765615c27 100644 --- a/default-views/auth/login.hbs +++ b/default-views/auth/login.hbs @@ -11,63 +11,45 @@

Login

-
-
- {{#if error}} -
-
-

{{error}}

-
-
- {{/if}} -
-
- - -
-
-
-
-
-
- -
- -
-
Don't have an account? - - Register - -
-
- -
-
Forgot password? - - Reset password - -
-
+
+
+
+
Don't have an account? + + Register +
+
- - - - - - - +
+
Forgot password? + + Reset password + +
+
{{> auth/test}}
- +
diff --git a/default-views/auth/username-password.hbs b/default-views/auth/username-password.hbs new file mode 100644 index 000000000..34a061321 --- /dev/null +++ b/default-views/auth/username-password.hbs @@ -0,0 +1,29 @@ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + + + + + + + + + +
+
From 9b19974296ef3051b5eb2c53b9297a85b32a1034 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Mon, 22 May 2017 14:31:56 -0400 Subject: [PATCH 055/178] Implement Login via WebID-TLS cert local auth strategy - Disable tls account creation --- config/defaults.js | 4 + default-views/account/register-disabled.hbs | 4 + default-views/account/register-form.hbs | 55 ++ default-views/account/register.hbs | 58 +-- default-views/auth/auth-hidden-fields.hbs | 7 + default-views/auth/login-tls.hbs | 10 + ...ssword.hbs => login-username-password.hbs} | 10 +- default-views/auth/login.hbs | 11 +- lib/api/accounts/user-accounts.js | 1 + lib/api/authn/webid-oidc.js | 9 +- lib/create-app.js | 6 +- lib/models/account-manager.js | 8 + lib/models/authenticator.js | 321 ++++++++++++ lib/models/user-account.js | 8 + lib/requests/auth-request.js | 181 +++++++ lib/requests/create-account-request.js | 154 +++--- lib/requests/login-request.js | 370 ++++--------- lib/requests/password-change-request.js | 17 +- lib/requests/password-reset-email-request.js | 5 +- package.json | 1 + test/integration/account-creation-tls.js | 454 ++++++++-------- test/integration/authentication-oidc.js | 10 +- test/unit/account-manager.js | 23 + test/unit/auth-request.js | 101 ++++ test/unit/authenticator.js | 34 ++ test/unit/create-account-request.js | 13 - test/unit/login-by-password-request.js | 489 ------------------ test/unit/login-request.js | 238 +++++++++ test/unit/password-authenticator.js | 228 ++++++++ test/unit/password-change-request.js | 1 - test/unit/tls-authenticator.js | 169 ++++++ 31 files changed, 1842 insertions(+), 1158 deletions(-) create mode 100644 default-views/account/register-disabled.hbs create mode 100644 default-views/account/register-form.hbs create mode 100644 default-views/auth/auth-hidden-fields.hbs create mode 100644 default-views/auth/login-tls.hbs rename default-views/auth/{username-password.hbs => login-username-password.hbs} (50%) create mode 100644 lib/models/authenticator.js create mode 100644 test/unit/auth-request.js create mode 100644 test/unit/authenticator.js delete mode 100644 test/unit/login-by-password-request.js create mode 100644 test/unit/login-request.js create mode 100644 test/unit/password-authenticator.js create mode 100644 test/unit/tls-authenticator.js diff --git a/config/defaults.js b/config/defaults.js index 232176caf..b67c66c81 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -2,6 +2,10 @@ module.exports = { 'auth': 'oidc', + 'localAuth': { + 'tls': true, + 'password': true + }, 'configPath': './config', 'dbPath': './.db', 'port': 8443, diff --git a/default-views/account/register-disabled.hbs b/default-views/account/register-disabled.hbs new file mode 100644 index 000000000..4a84e3660 --- /dev/null +++ b/default-views/account/register-disabled.hbs @@ -0,0 +1,4 @@ +

+ Registering a new account is disabled for the WebID-TLS authentication method. + Please restart the server using another mode. +

diff --git a/default-views/account/register-form.hbs b/default-views/account/register-form.hbs new file mode 100644 index 000000000..d0d721022 --- /dev/null +++ b/default-views/account/register-form.hbs @@ -0,0 +1,55 @@ +
+
+ {{#if error}} +
+
+

{{error}}

+
+
+ {{/if}} +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+ +
+
+
+ + + {{> auth/auth-hidden-fields}} +
+ +
+
Already have an account? + + Log In + +
+
+
+
+
diff --git a/default-views/account/register.hbs b/default-views/account/register.hbs index 769449fba..12ab66a9f 100644 --- a/default-views/account/register.hbs +++ b/default-views/account/register.hbs @@ -11,59 +11,11 @@

Register

-
-
- {{#if error}} -
-
-

{{error}}

-
-
- {{/if}} -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- -
- -
-
-
- -
- -
-
Already have an account? - - Log In - -
-
-
-
-
+ {{#if registerDisabled}} + {{> account/register-disabled}} + {{else}} + {{> account/register-form}} + {{/if}}
diff --git a/default-views/auth/auth-hidden-fields.hbs b/default-views/auth/auth-hidden-fields.hbs new file mode 100644 index 000000000..ddfe82507 --- /dev/null +++ b/default-views/auth/auth-hidden-fields.hbs @@ -0,0 +1,7 @@ + + + + + + + diff --git a/default-views/auth/login-tls.hbs b/default-views/auth/login-tls.hbs new file mode 100644 index 000000000..6a98e3c6c --- /dev/null +++ b/default-views/auth/login-tls.hbs @@ -0,0 +1,10 @@ +
+
+ + + + {{> auth/auth-hidden-fields}} +
+
diff --git a/default-views/auth/username-password.hbs b/default-views/auth/login-username-password.hbs similarity index 50% rename from default-views/auth/username-password.hbs rename to default-views/auth/login-username-password.hbs index 34a061321..19ba04a29 100644 --- a/default-views/auth/username-password.hbs +++ b/default-views/auth/login-username-password.hbs @@ -1,4 +1,4 @@ -
+
@@ -18,12 +18,6 @@ - - - - - - - + {{> auth/auth-hidden-fields}}
diff --git a/default-views/auth/login.hbs b/default-views/auth/login.hbs index 765615c27..e4bf37a65 100644 --- a/default-views/auth/login.hbs +++ b/default-views/auth/login.hbs @@ -20,11 +20,15 @@ {{/if}}
- {{> auth/username-password}} + {{#if enablePassword}} + {{> auth/login-username-password}} + {{/if}}

- With Certificate (WebId-TLS) + {{#if enableTls}} + {{> auth/login-tls}} + {{/if}}
@@ -34,7 +38,7 @@
@@ -47,7 +51,6 @@ Reset password
-
{{> auth/test}}
diff --git a/lib/api/accounts/user-accounts.js b/lib/api/accounts/user-accounts.js index 06c98c541..e3f704e8f 100644 --- a/lib/api/accounts/user-accounts.js +++ b/lib/api/accounts/user-accounts.js @@ -18,6 +18,7 @@ const AddCertificateRequest = require('../../requests/add-cert-request') function checkAccountExists (accountManager) { return (req, res, next) => { let accountUri = req.hostname + accountManager.accountUriExists(accountUri) .then(found => { if (!found) { diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 6756744df..6a67e616d 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -6,7 +6,7 @@ const express = require('express') const bodyParser = require('body-parser').urlencoded({ extended: false }) -const { LoginByPasswordRequest } = require('../../requests/login-request') +const { LoginRequest } = require('../../requests/login-request') const PasswordResetEmailRequest = require('../../requests/password-reset-email-request') const PasswordChangeRequest = require('../../requests/password-change-request') @@ -33,8 +33,11 @@ function middleware (oidc) { router.get('/api/auth/select-provider', SelectProviderRequest.get) router.post('/api/auth/select-provider', bodyParser, SelectProviderRequest.post) - router.get(['/login', '/signin'], LoginByPasswordRequest.get) - router.post(['/login', '/signin'], bodyParser, LoginByPasswordRequest.post) + router.get(['/login', '/signin'], LoginRequest.get) + + router.post('/login/password', bodyParser, LoginRequest.loginPassword) + + router.post('/login/tls', bodyParser, LoginRequest.loginTls) router.get('/account/password/reset', PasswordResetEmailRequest.get) router.post('/account/password/reset', bodyParser, PasswordResetEmailRequest.post) diff --git a/lib/create-app.js b/lib/create-app.js index 970c9c409..7e4ddd04f 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -89,6 +89,7 @@ function initAppLocals (app, argv, ldp) { app.locals.appUrls = argv.apps // used for service capability discovery app.locals.host = argv.host app.locals.authMethod = argv.auth + app.locals.localAuth = argv.localAuth app.locals.tokenService = new TokenService() if (argv.email && argv.email.host) { @@ -125,7 +126,10 @@ function initViews (app, configPath) { const viewsPath = config.initDefaultViews(configPath) app.set('views', viewsPath) - app.engine('.hbs', handlebars({ extname: '.hbs' })) + app.engine('.hbs', handlebars({ + extname: '.hbs', + partialsDir: viewsPath + })) app.set('view engine', '.hbs') } diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 6026599ef..8a6268668 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -392,6 +392,14 @@ class AccountManager { }) } + externalAccount (webId) { + let webIdHostname = url.parse(webId).hostname + + let serverHostname = this.host.hostname + + return !webIdHostname.endsWith(serverHostname) + } + /** * Generates an expiring one-time-use token for password reset purposes * (the user's Web ID is saved in the token service). diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js new file mode 100644 index 000000000..c06e18673 --- /dev/null +++ b/lib/models/authenticator.js @@ -0,0 +1,321 @@ +'use strict' + +const debug = require('./../debug').authentication +const validUrl = require('valid-url') +const webid = require('webid/tls') + +/** + * Abstract Authenticator class, representing a local login strategy. + * To subclass, implement `fromParams()` and `findValidUser()`. + * Used by the `LoginRequest` handler class. + * + * @abstract + * @class Authenticator + */ +class Authenticator { + constructor (options) { + this.accountManager = options.accountManager + } + + /** + * @param req {IncomingRequest} + * @param options {Object} + */ + static fromParams (req, options) { + throw new Error('Must override method') + } + + /** + * @returns {Promise} + */ + findValidUser () { + throw new Error('Must override method') + } +} + +/** + * Authenticates user via Username+Password. + */ +class PasswordAuthenticator extends Authenticator { + /** + * @constructor + * @param options {Object} + * + * @param [options.username] {string} Unique identifier submitted by user + * from the Login form. Can be one of: + * - An account name (e.g. 'alice'), if server is in Multi-User mode + * - A WebID URI (e.g. 'https://alice.example.com/#me') + * + * @param [options.password] {string} Plaintext password as submitted by user + * + * @param [options.userStore] {UserStore} + * + * @param [options.accountManager] {AccountManager} + */ + constructor (options) { + super(options) + + this.userStore = options.userStore + this.username = options.username + this.password = options.password + } + + /** + * Factory method, returns an initialized instance of PasswordAuthenticator + * from an incoming http request. + * + * @param req {IncomingRequest} + * @param [req.body={}] {Object} + * @param [req.body.username] {string} + * @param [req.body.password] {string} + * + * @param options {Object} + * + * @param [options.accountManager] {AccountManager} + * @param [options.userStore] {UserStore} + * + * @return {PasswordAuthenticator} + */ + static fromParams (req, options) { + let body = req.body || {} + + options.username = body.username + options.password = body.password + + return new PasswordAuthenticator(options) + } + + /** + * Ensures required parameters are present, + * and throws an error if not. + * + * @throws {Error} If missing required params + */ + validate () { + let error + + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 + throw error + } + + if (!this.password) { + error = new Error('Password required') + error.statusCode = 400 + throw error + } + } + + /** + * Loads a user from the user store, and if one is found and the + * password matches, returns a `UserAccount` instance for that user. + * + * @throws {Error} If failures to load user are encountered + * + * @return {Promise} + */ + findValidUser () { + let error + let userOptions + + return Promise.resolve() + .then(() => this.validate()) + .then(() => { + if (validUrl.isUri(this.username)) { + // A WebID URI was entered into the username field + userOptions = { webId: this.username } + } else { + // A regular username + userOptions = { username: this.username } + } + + let user = this.accountManager.userAccountFrom(userOptions) + + debug(`Attempting to login user: ${user.id}`) + + return this.userStore.findUser(user.id) + }) + .then(foundUser => { + if (!foundUser) { + error = new Error('No user found for that username') + error.statusCode = 400 + throw error + } + + return this.userStore.matchPassword(foundUser, this.password) + }) + .then(validUser => { + if (!validUser) { + error = new Error('User found but no password match') + error.statusCode = 400 + throw error + } + + debug('User found, password matches') + + return this.accountManager.userAccountFrom(validUser) + }) + } +} + +/** + * Authenticates a user via a WebID-TLS client side certificate. + */ +class TlsAuthenticator extends Authenticator { + /** + * @constructor + * @param options {Object} + * + * @param [options.accountManager] {AccountManager} + * + * @param [options.connection] {Socket} req.connection + */ + constructor (options) { + super(options) + + this.connection = options.connection + } + + /** + * Factory method, returns an initialized instance of TlsAuthenticator + * from an incoming http request. + * + * @param req {IncomingRequest} + * @param req.connection {Socket} + * + * @param options {Object} + * @param [options.accountManager] {AccountManager} + * + * @return {TlsAuthenticator} + */ + static fromParams (req, options) { + options.connection = req.connection + + return new TlsAuthenticator(options) + } + + /** + * Requests a client certificate from the current TLS connection via + * renegotiation, extracts and verifies the user's WebID URI, + * and makes sure that WebID is hosted on this server. + * + * @throws {Error} If error is encountered extracting the WebID URI from + * certificate, or if the user's account is hosted by a remote system. + * + * @return {Promise} + */ + findValidUser () { + return this.renegotiateTls() + + .then(() => this.getCertificate()) + + .then(cert => this.extractWebId(cert)) + + .then(webId => this.ensureLocalUser(webId)) + } + + /** + * Renegotiates the current TLS connection to ask for a client certificate. + * + * @throws {Error} + * + * @returns {Promise} + */ + renegotiateTls () { + let connection = this.connection + + return new Promise((resolve, reject) => { + connection.renegotiate({ requestCert: true }, (error) => { + if (error) { + debug('Error renegotiating TLS:', error) + + return reject(error) + } + + resolve() + }) + }) + } + + /** + * Requests and returns a client TLS certificate from the current connection. + * + * @throws {Error} If no certificate is presented, or if it is empty. + * + * @return {Promise} + */ + getCertificate () { + let certificate = this.connection.getPeerCertificate() + + if (!certificate || !Object.keys(certificate).length) { + debug('No client certificate detected') + + throw new Error('No client certificate detected. ' + + '(You may need to restart your browser to retry.)') + } + + return certificate + } + + /** + * Extracts (and verifies) the WebID URI from a client certificate. + * + * @param certificate {X509Certificate} + * + * @return {Promise} WebID URI + */ + extractWebId (certificate) { + return new Promise((resolve, reject) => { + this.verifyWebId(certificate, (error, webId) => { + if (error) { + debug('Error processing certificate:', error) + + return reject(error) + } + + resolve(webId) + }) + }) + } + + /** + * Performs WebID-TLS verification (requests the WebID Profile from the + * WebID URI extracted from certificate, and makes sure the public key + * from the profile matches the key from certificate). + * + * @param certificate {X509Certificate} + * @param callback {Function} Gets invoked with signature `callback(error, webId)` + */ + verifyWebId (certificate, callback) { + debug('Verifying WebID URI') + + webid.verify(certificate, callback) + } + + /** + * Ensures that the extracted WebID URI is hosted on this server. If it is, + * returns a UserAccount instance for that WebID, throws an error otherwise. + * + * @param webId {string} + * + * @throws {Error} If the account is not hosted on this server + * + * @return {UserAccount} + */ + ensureLocalUser (webId) { + if (this.accountManager.externalAccount(webId)) { + debug(`WebID URI ${JSON.stringify(webId)} is not a local account`) + + throw new Error('Cannot login: Selected Web ID is not hosted on this server') + } + + return this.accountManager.userAccountFrom({ webId }) + } +} + +module.exports = { + Authenticator, + PasswordAuthenticator, + TlsAuthenticator +} diff --git a/lib/models/user-account.js b/lib/models/user-account.js index dc47a1e0b..5c9b56fa5 100644 --- a/lib/models/user-account.js +++ b/lib/models/user-account.js @@ -64,6 +64,14 @@ class UserAccount { return id } + get accountUri () { + if (!this.webId) { return null } + + let parsed = url.parse(this.webId) + + return parsed.protocol + '//' + parsed.host + } + /** * Returns the URI of the WebID Profile for this account. * Usage: diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js index 4558ffd89..767f177b3 100644 --- a/lib/requests/auth-request.js +++ b/lib/requests/auth-request.js @@ -1,9 +1,41 @@ 'use strict' +const url = require('url') +const debug = require('./../debug').authentication + +/** + * Hidden form fields from the login page that must be passed through to the + * Authentication request. + * + * @type {Array} + */ +const AUTH_QUERY_PARAMS = ['response_type', 'display', 'scope', + 'client_id', 'redirect_uri', 'state', 'nonce'] + /** * Base authentication request (used for login and password reset workflows). */ class AuthRequest { + /** + * @constructor + * @param [options.response] {ServerResponse} middleware `res` object + * @param [options.session] {Session} req.session + * @param [options.userStore] {UserStore} + * @param [options.accountManager] {AccountManager} + * @param [options.returnToUrl] {string} + * @param [options.authQueryParams] {Object} Key/value hashmap of parsed query + * parameters that will be passed through to the /authorize endpoint. + */ + constructor (options) { + this.response = options.response + this.session = options.session || {} + this.userStore = options.userStore + this.accountManager = options.accountManager + this.returnToUrl = options.returnToUrl + this.authQueryParams = options.authQueryParams || {} + this.localAuth = options.localAuth + } + /** * Extracts a given parameter from the request - either from a GET query param, * a POST body param, or an express registered `/:param`. @@ -26,6 +58,155 @@ class AuthRequest { return query[parameter] || body[parameter] || params[parameter] || null } + + /** + * Extracts the options in common to most auth-related requests. + * + * @param req + * @param res + * + * @return {Object} + */ + static requestOptions (req, res) { + let userStore, accountManager, localAuth + + if (req.app && req.app.locals) { + let locals = req.app.locals + + if (locals.oidc) { + userStore = locals.oidc.users + } + + accountManager = locals.accountManager + + localAuth = locals.localAuth + } + + let authQueryParams = AuthRequest.extractAuthParams(req) + let returnToUrl = AuthRequest.parseParameter(req, 'returnToUrl') + + let options = { + response: res, + session: req.session, + userStore, + accountManager, + returnToUrl, + authQueryParams, + localAuth + } + + return options + } + + /** + * Initializes query params required by Oauth2/OIDC type work flow from the + * request body. + * Only authorized params are loaded, all others are discarded. + * + * @param req {IncomingRequest} + * + * @return {Object} + */ + static extractAuthParams (req) { + let params + if (req.method === 'POST') { + params = req.body + } else { + params = req.query + } + + if (!params) { return {} } + + let extracted = {} + + let paramKeys = AUTH_QUERY_PARAMS + let value + + for (let p of paramKeys) { + value = params[p] + // value = value === 'undefined' ? undefined : value + extracted[p] = value + } + + return extracted + } + + /** + * Calls the appropriate form to display to the user. + * Serves as an error handler for this request workflow. + * + * @param error {Error} + */ + error (error) { + error.statusCode = error.statusCode || 400 + + this.renderForm(error) + } + + /** + * Initializes a session (for subsequent authentication/authorization) with + * a given user's credentials. + * + * @param userAccount {UserAccount} + */ + initUserSession (userAccount) { + let session = this.session + + debug('Initializing user session with webId: ', userAccount.webId) + + session.userId = userAccount.webId + session.identified = true + session.subject = { + _id: userAccount.webId + } + + return userAccount + } + + /** + * Returns this installation's /authorize url. Used for redirecting post-login + * and post-signup. + * + * @return {string} + */ + authorizeUrl () { + let host = this.accountManager.host + let authUrl = host.authEndpoint + + authUrl.query = this.authQueryParams + + return url.format(authUrl) + } + + /** + * Returns this installation's /register url. Used for redirecting post-signup. + * + * @return {string} + */ + registerUrl () { + let host = this.accountManager.host + let signupUrl = url.parse(url.resolve(host.serverUri, '/register')) + + signupUrl.query = this.authQueryParams + + return url.format(signupUrl) + } + + /** + * Returns this installation's /login url. + * + * @return {string} + */ + loginUrl () { + let host = this.accountManager.host + let signupUrl = url.parse(url.resolve(host.serverUri, '/login')) + + signupUrl.query = this.authQueryParams + + return url.format(signupUrl) + } } +AuthRequest.AUTH_QUERY_PARAMS = AUTH_QUERY_PARAMS + module.exports = AuthRequest diff --git a/lib/requests/create-account-request.js b/lib/requests/create-account-request.js index fbe2241ac..33470a5e6 100644 --- a/lib/requests/create-account-request.js +++ b/lib/requests/create-account-request.js @@ -28,12 +28,10 @@ class CreateAccountRequest extends AuthRequest { * this url on successful account creation */ constructor (options) { - super() - this.accountManager = options.accountManager + super(options) + + this.username = options.username this.userAccount = options.userAccount - this.session = options.session - this.response = options.response - this.returnToUrl = options.returnToUrl } /** @@ -50,68 +48,68 @@ class CreateAccountRequest extends AuthRequest { * @return {CreateAccountRequest|CreateTlsAccountRequest} */ static fromParams (req, res) { - if (!req.body.username) { - throw new Error('Username required to create an account') - } + let options = AuthRequest.requestOptions(req, res) let locals = req.app.locals - let accountManager = locals.accountManager let authMethod = locals.authMethod - let returnToUrl = this.parseParameter(req, 'returnToUrl') - let userAccount = accountManager.userAccountFrom(req.body) - - let options = { - accountManager, - userAccount, - session: req.session, - response: res, - returnToUrl + let accountManager = locals.accountManager + + let body = req.body || {} + + options.username = body.username + + if (options.username) { + options.userAccount = accountManager.userAccountFrom(body) } switch (authMethod) { case 'oidc': - options.password = req.body.password - options.userStore = locals.oidc.users + options.password = body.password return new CreateOidcAccountRequest(options) case 'tls': - options.spkac = req.body.spkac + options.spkac = body.spkac return new CreateTlsAccountRequest(options) default: throw new TypeError('Unsupported authentication scheme') } } - static renderView (response, returnToUrl, error) { - let params = { returnToUrl } - - if (error) { - response.status(error.statusCode || 400) - params.error = error.message - } + static post (req, res) { + let request = CreateAccountRequest.fromParams(req, res) - response.render('account/register', params) + return Promise.resolve() + .then(() => request.validate()) + .then(() => request.createAccount()) + .catch(error => request.error(error)) } - static post (req, res) { - let request - let returnToUrl = req.body.returnToUrl + static get (req, res) { + let request = CreateAccountRequest.fromParams(req, res) - try { - request = CreateAccountRequest.fromParams(req, res) - } catch (error) { - return CreateAccountRequest.renderView(res, returnToUrl, error) - } + return Promise.resolve() + .then(() => request.renderForm()) + .catch(error => request.error(error)) + } - return request.createAccount() - .catch(error => { - CreateAccountRequest.renderView(res, returnToUrl, error) + /** + * Renders the Register form + */ + renderForm (error) { + let authMethod = this.accountManager.authMethod + + let params = Object.assign({}, this.authQueryParams, + { + returnToUrl: this.returnToUrl, + loginUrl: this.loginUrl(), + registerDisabled: authMethod === 'tls' }) - } - static get (req, res) { - let returnToUrl = req.query.returnToUrl + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } - CreateAccountRequest.renderView(res, returnToUrl) + this.response.render('account/register', params) } /** @@ -129,7 +127,7 @@ class CreateAccountRequest extends AuthRequest { .then(this.cancelIfAccountExists.bind(this)) .then(this.createAccountStorage.bind(this)) .then(this.saveCredentialsFor.bind(this)) - .then(this.initSession.bind(this)) + .then(this.initUserSession.bind(this)) .then(this.sendResponse.bind(this)) .then(userAccount => { // 'return' not used deliberately, no need to block and wait for email @@ -189,23 +187,6 @@ class CreateAccountRequest extends AuthRequest { return userAccount }) } - - /** - * Initializes the session with the newly created user's credentials - * - * @param userAccount {UserAccount} Instance of the account to be created - * - * @return {UserAccount} Chainable - */ - initSession (userAccount) { - let session = this.session - - if (!session) { return userAccount } - - session.userId = userAccount.webId - session.identified = true - return userAccount - } } /** @@ -223,16 +204,32 @@ class CreateOidcAccountRequest extends CreateAccountRequest { * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring * @param [options.password] {string} Password, as entered by the user at signup */ - constructor (options = {}) { - if (!options.password) { - let error = new Error('Password required to create an account') - error.status = 400 + constructor (options) { + super(options) + + this.password = options.password + } + + /** + * Validates the Login request (makes sure required parameters are present), + * and throws an error if not. + * + * @throws {Error} If missing required params + */ + validate () { + let error + + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 throw error } - super(options) - this.password = options.password - this.userStore = options.userStore + if (!this.password) { + error = new Error('Password required') + error.statusCode = 400 + throw error + } } /** @@ -273,12 +270,29 @@ class CreateTlsAccountRequest extends CreateAccountRequest { * @param [options={}] {Object} See `CreateAccountRequest` constructor docstring * @param [options.spkac] {string} */ - constructor (options = {}) { + constructor (options) { super(options) + this.spkac = options.spkac this.certificate = null } + /** + * Validates the Login request (makes sure required parameters are present), + * and throws an error if not. + * + * @throws {Error} If missing required params + */ + validate () { + let error + + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 + throw error + } + } + /** * Generates a new X.509v3 RSA certificate (if `spkac` was passed in) and * adds it to the user account. Used for storage in an agent's WebID diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index b38178ffc..7ce699f15 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -1,299 +1,148 @@ 'use strict' -const url = require('url') -const validUrl = require('valid-url') - const debug = require('./../debug').authentication +const AuthRequest = require('./auth-request') +const { PasswordAuthenticator, TlsAuthenticator } = require('../models/authenticator') + +const PASSWORD_AUTH = 'password' +const TLS_AUTH = 'tls' + /** - * Models a Login request, a POST submit from a Login form with a username and - * password. Used with authMethod of 'oidc'. - * - * For usage example, see `post()` and `get()` docstrings, below. + * Models a local Login request */ -class LoginByPasswordRequest { +class LoginRequest extends AuthRequest { /** * @constructor * @param options {Object} * - * @param [options.username] {string} Unique identifier submitted by user - * from the Login form. Can be one of: - * - An account name (e.g. 'alice'), if server is in Multi-User mode - * - A WebID URI (e.g. 'https://alice.example.com/#me') - * - * @param [options.password] {string} Plaintext password as submitted by user - * * @param [options.response] {ServerResponse} middleware `res` object * @param [options.session] {Session} req.session * @param [options.userStore] {UserStore} * @param [options.accountManager] {AccountManager} + * @param [options.returnToUrl] {string} * @param [options.authQueryParams] {Object} Key/value hashmap of parsed query * parameters that will be passed through to the /authorize endpoint. + * @param [options.authenticator] {Authenticator} Auth strategy by which to + * log in */ constructor (options) { - this.username = options.username - this.password = options.password - this.response = options.response - this.session = options.session || {} - this.userStore = options.userStore - this.accountManager = options.accountManager - this.authQueryParams = options.authQueryParams || {} - } - - /** - * Handles a Login GET request on behalf of a middleware handler. Usage: - * - * ``` - * app.get('/login', LoginByPasswordRequest.get) - * ``` - * - * @param req {IncomingRequest} - * @param res {ServerResponse} - */ - static get (req, res) { - const request = LoginByPasswordRequest.fromParams(req, res) + super(options) - request.renderView() + this.authenticator = options.authenticator } /** - * Handles a Login POST request on behalf of a middleware handler. Usage: - * - * ``` - * app.post('/login', LoginByPasswordRequest.post) - * ``` - * - * @param req {IncomingRequest} - * @param res {ServerResponse} - * - * @return {Promise} - */ - static post (req, res) { - const request = LoginByPasswordRequest.fromParams(req, res) - - return LoginByPasswordRequest.login(request) - .catch(request.error.bind(request)) - } - - /** - * Factory method, returns an initialized instance of LoginByPasswordRequest + * Factory method, returns an initialized instance of LoginRequest * from an incoming http request. * * @param req {IncomingRequest} * @param res {ServerResponse} + * @param authMethod {string} * - * @return {LoginByPasswordRequest} + * @return {LoginRequest} */ - static fromParams (req, res) { - let body = req.body || {} - - let userStore, accountManager + static fromParams (req, res, authMethod) { + let options = AuthRequest.requestOptions(req, res) - if (req.app && req.app.locals) { - let locals = req.app.locals + switch (authMethod) { + case PASSWORD_AUTH: + options.authenticator = PasswordAuthenticator.fromParams(req, options) + break - if (locals.oidc) { - userStore = locals.oidc.users - } - - accountManager = locals.accountManager - } + case TLS_AUTH: + options.authenticator = TlsAuthenticator.fromParams(req, options) + break - let options = { - username: body.username, - password: body.password, - response: res, - session: req.session, - userStore, - accountManager, - authQueryParams: LoginByPasswordRequest.extractParams(req) + default: + options.authenticator = null + break } - return new LoginByPasswordRequest(options) + return new LoginRequest(options) } /** - * Performs the login operation -- validates required parameters, loads the - * appropriate user, inits the session if passwords match, and redirects the - * user to continue their OIDC auth flow. + * Handles a Login GET request on behalf of a middleware handler, displays + * the Login page. + * Usage: * - * @param request {LoginByPasswordRequest} - * - * @throws {Error} HTTP 400 error if required parameters are missing, or - * if the user is not found or the password does not match. + * ``` + * app.get('/login', LoginRequest.get) + * ``` * - * @return {Promise} + * @param req {IncomingRequest} + * @param res {ServerResponse} */ - static login (request) { - return Promise.resolve() - .then(() => { - request.validate() + static get (req, res) { + const request = LoginRequest.fromParams(req, res) - return request.findValidUser() - }) - .then(validUser => { - request.initUserSession(validUser) - request.redirectPostLogin(validUser) - }) + request.renderForm() } /** - * Initializes query params required by OIDC work flow from the request body. - * Only authorized params are loaded, all others are discarded. + * Handles a Login via Username+Password. + * Errors encountered are displayed on the Login form. + * Usage: * - * @param req {IncomingRequest} + * ``` + * app.post('/login/password', LoginRequest.loginPassword) + * ``` * - * @return {Object} - */ - static extractParams (req) { - let params - if (req.method === 'POST') { - params = req.body || {} - } else { - params = req.query || {} - } - - let extracted = {} - - let paramKeys = LoginByPasswordRequest.AUTH_QUERY_PARAMS - let value - - for (let p of paramKeys) { - value = params[p] - value = value === 'undefined' ? undefined : value - extracted[p] = value - } - - return extracted - } - - /** - * Renders the login form along with the provided error. - * Serves as an error handler for this request workflow. + * @param req + * @param res * - * @param error {Error} + * @return {Promise} */ - error (error) { - let res = this.response - let params = Object.assign({}, this.authQueryParams, {error: error.message}) - - res.status(error.statusCode || 400) + static loginPassword (req, res) { + debug('Logging in via username + password') - res.render('auth/login', params) - } - - /** - * Renders the login form - */ - renderView () { - let res = this.response - let params = Object.assign({}, this.authQueryParams, - { postRegisterUrl: this.postRegisterUrl() }) + let request = LoginRequest.fromParams(req, res, PASSWORD_AUTH) - res.render('auth/login', params) + return LoginRequest.login(request) } /** - * Validates the Login request (makes sure required parameters are present), - * and throws an error if not. + * Handles a Login via WebID-TLS. + * Errors encountered are displayed on the Login form. + * Usage: + * + * ``` + * app.post('/login/tls', LoginRequest.loginTls) + * ``` * - * @throws {Error} If missing required params + * @param req + * @param res + * + * @return {Promise} */ - validate () { - let error + static loginTls (req, res) { + debug('Logging in via WebID-TLS certificate') - if (!this.username) { - error = new Error('Username required') - error.statusCode = 400 - throw error - } + let request = LoginRequest.fromParams(req, res, TLS_AUTH) - if (!this.password) { - error = new Error('Password required') - error.statusCode = 400 - throw error - } + return LoginRequest.login(request) } /** - * Loads a user from the user store, and if one is found and the - * password matches, returns a `UserAccount` instance for that user. + * Performs the login operation -- loads and validates the + * appropriate user, inits the session with credentials, and redirects the + * user to continue their auth flow. * - * @throws {Error} If failures to load user are encountered + * @param request {LoginRequest} * - * @return {Promise} + * @return {Promise} */ - findValidUser () { - let error - let userOptions - - if (validUrl.isUri(this.username)) { - // A WebID URI was entered into the username field - userOptions = { webId: this.username } - } else { - // A regular username - userOptions = { username: this.username } - } - - return Promise.resolve() - .then(() => { - let user = this.accountManager.userAccountFrom(userOptions) - - debug(`Attempting to login user: ${user.id}`) + static login (request) { + return request.authenticator.findValidUser() - return this.userStore.findUser(user.id) - }) - .then(foundUser => { - if (!foundUser) { - error = new Error('No user found for that username') - error.statusCode = 400 - throw error - } - - return this.userStore.matchPassword(foundUser, this.password) - }) .then(validUser => { - if (!validUser) { - error = new Error('User found but no password match') - error.statusCode = 400 - throw error - } - - debug('User found, password matches') + request.initUserSession(validUser) - return this.accountManager.userAccountFrom(validUser) + request.redirectPostLogin(validUser) }) - } - - /** - * Initializes a session (for subsequent authentication/authorization) with - * a given user's credentials. - * - * @param validUser {UserAccount} - */ - initUserSession (validUser) { - let session = this.session - debug('Initializing user session with webId: ', validUser.webId) - - session.userId = validUser.webId - session.identified = true - session.subject = { - _id: validUser.webId - } - } - - /** - * Returns the /authorize url to redirect the user to after the login form. - * - * @return {string} - */ - authorizeUrl () { - let host = this.accountManager.host - let authUrl = host.authEndpoint - authUrl.query = this.authQueryParams - - return url.format(authUrl) + .catch(error => request.error(error)) } /** @@ -313,58 +162,47 @@ class LoginByPasswordRequest { uri = this.authorizeUrl() } else if (validUser) { // Login request is a user going to /login in browser - uri = this.accountManager.accountUriFor(validUser.username) + // uri = this.accountManager.accountUriFor(validUser.username) + uri = validUser.accountUri } return uri } /** - * Returns a URL to redirect the user to after registration (used for the - * 'No account? Register for a new one' link on the /login page). - * Either uses the provided `redirect_uri` auth query param, or just uses - * the server uri. - * - * @param validUser {UserAccount} - * - * @return {string} + * Redirects the Login request to continue on the OIDC auth workflow. */ - postRegisterUrl () { - let uri - - if (this.authQueryParams['redirect_uri']) { - // Login/register request is part of an app's auth flow - uri = this.authorizeUrl() - } else { - // User went to /register directly, not part of an auth flow - let host = this.accountManager.host - uri = host.serverUri - } + redirectPostLogin (validUser) { + let uri = this.postLoginUrl(validUser) - uri = encodeURIComponent(uri) + debug('Login successful, redirecting to ', uri) - return uri + this.response.redirect(uri) } /** - * Redirects the Login request to continue on the OIDC auth workflow. + * Renders the login form */ - redirectPostLogin (validUser) { - let uri = this.postLoginUrl(validUser) + renderForm (error) { + let params = Object.assign({}, this.authQueryParams, + { + registerUrl: this.registerUrl(), + returnToUrl: this.returnToUrl, + enablePassword: this.localAuth.password, + enableTls: this.localAuth.tls + }) - debug('Login successful, redirecting to ', uri) + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } - this.response.redirect(uri) + this.response.render('auth/login', params) } } -/** - * Hidden form fields from the login page that must be passed through to the - * Authentication request. - * - * @type {Array} - */ -LoginByPasswordRequest.AUTH_QUERY_PARAMS = ['response_type', 'display', 'scope', - 'client_id', 'redirect_uri', 'state', 'nonce'] - -module.exports.LoginByPasswordRequest = LoginByPasswordRequest +module.exports = { + LoginRequest, + PASSWORD_AUTH, + TLS_AUTH +} diff --git a/lib/requests/password-change-request.js b/lib/requests/password-change-request.js index 081c8a138..5ac1a76a9 100644 --- a/lib/requests/password-change-request.js +++ b/lib/requests/password-change-request.js @@ -15,10 +15,7 @@ class PasswordChangeRequest extends AuthRequest { * @param [options.newPassword] {string} New password to save */ constructor (options) { - super() - this.accountManager = options.accountManager - this.userStore = options.userStore - this.response = options.response + super(options) this.token = options.token this.returnToUrl = options.returnToUrl @@ -173,16 +170,6 @@ class PasswordChangeRequest extends AuthRequest { }) } - /** - * Renders the 'change password' form along with the provided error. - * Serves as an error handler for this request workflow. - * - * @param error {Error} - */ - error (error) { - this.renderForm(error) - } - /** * Renders the 'change password' form. * @@ -197,7 +184,7 @@ class PasswordChangeRequest extends AuthRequest { if (error) { params.error = error.message - this.response.status(error.statusCode || 400) + this.response.status(error.statusCode) } this.response.render('auth/change-password', params) diff --git a/lib/requests/password-reset-email-request.js b/lib/requests/password-reset-email-request.js index 315482dd3..4bff4594f 100644 --- a/lib/requests/password-reset-email-request.js +++ b/lib/requests/password-reset-email-request.js @@ -13,9 +13,8 @@ class PasswordResetEmailRequest extends AuthRequest { * @param [options.username] {string} Username / account name (e.g. 'alice') */ constructor (options) { - super() - this.accountManager = options.accountManager - this.response = options.response + super(options) + this.returnToUrl = options.returnToUrl this.username = options.username } diff --git a/package.json b/package.json index 2eb848c76..0c47eb2ff 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "chai": "^3.5.0", + "chai-as-promised": "^6.0.0", "dirty-chai": "^1.2.2", "hippie": "^0.5.0", "mocha": "^3.2.0", diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls.js index 295c0fb9d..7b161b8dc 100644 --- a/test/integration/account-creation-tls.js +++ b/test/integration/account-creation-tls.js @@ -1,227 +1,227 @@ -const supertest = require('supertest') -// Helper functions for the FS -const $rdf = require('rdflib') - -const { rm, read } = require('../test-utils') -const ldnode = require('../../index') -const fs = require('fs-extra') -const path = require('path') - -describe('AccountManager (account creation tests)', function () { - this.timeout(10000) - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - - var address = 'https://localhost:3457' - var host = 'localhost:3457' - var ldpHttpsServer - let rootPath = path.join(__dirname, '../resources/accounts/') - var ldp = ldnode.createServer({ - root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - auth: 'tls', - webid: true, - idp: true, - strictOrigin: true - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(3457, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(rootPath, 'localhost/index.html')) - fs.removeSync(path.join(rootPath, 'localhost/index.html.acl')) - }) - - var server = supertest(address) - - it('should expect a 404 on GET /accounts', function (done) { - server.get('/api/accounts') - .expect(404, done) - }) - - describe('accessing accounts', function () { - it('should be able to access public file of an account', function (done) { - var subdomain = supertest('https://tim.' + host) - subdomain.get('/hello.html') - .expect(200, done) - }) - it('should get 404 if root does not exist', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.get('/') - .set('Accept', 'text/turtle') - .set('Origin', 'http://example.com') - .expect(404) - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect('Access-Control-Allow-Credentials', 'true') - .end(function (err, res) { - done(err) - }) - }) - }) - - describe('generating a certificate', () => { - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - after(function () { - rm('accounts/nicola.localhost') - }) - - it('should generate a certificate if spkac is valid', (done) => { - var spkac = read('example_spkac.cnf') - var subdomain = supertest.agent('https://nicola.' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect('Content-Type', /application\/x-x509-user-cert/) - .expect(200, done) - }) - - it('should not generate a certificate if spkac is not valid', (done) => { - var subdomain = supertest('https://nicola.' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola') - .expect(200) - .end((err) => { - if (err) return done(err) - - subdomain.post('/api/accounts/cert') - .send('username=nicola&spkac=') - .expect(400, done) - }) - }) - }) - - describe('creating an account with POST', function () { - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - - after(function () { - rm('accounts/nicola.localhost') - }) - - it('should not create WebID if no username is given', (done) => { - let subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=&spkac=' + spkac) - .expect(400, done) - }) - - it('should not create a WebID if it already exists', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end((err) => { - if (err) { - return done(err) - } - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(400) - .end((err) => { - done(err) - }) - }) - }) - - it('should create the default folders', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end(function (err) { - if (err) { - return done(err) - } - var domain = host.split(':')[0] - var card = read(path.join('accounts/nicola.' + domain, - 'profile/card')) - var cardAcl = read(path.join('accounts/nicola.' + domain, - 'profile/card.acl')) - var prefs = read(path.join('accounts/nicola.' + domain, - 'settings/prefs.ttl')) - var inboxAcl = read(path.join('accounts/nicola.' + domain, - 'inbox/.acl')) - var rootMeta = read(path.join('accounts/nicola.' + domain, '.meta')) - var rootMetaAcl = read(path.join('accounts/nicola.' + domain, - '.meta.acl')) - - if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && - rootMetaAcl) { - done() - } else { - done(new Error('failed to create default files')) - } - }) - }) - - it('should link WebID to the root account', function (done) { - var subdomain = supertest('https://nicola.' + host) - let spkac = read('example_spkac.cnf') - subdomain.post('/api/accounts/new') - .send('username=nicola&spkac=' + spkac) - .expect(200) - .end(function (err) { - if (err) { - return done(err) - } - subdomain.get('/.meta') - .expect(200) - .end(function (err, data) { - if (err) { - return done(err) - } - var graph = $rdf.graph() - $rdf.parse( - data.text, - graph, - 'https://nicola.' + host + '/.meta', - 'text/turtle') - var statements = graph.statementsMatching( - undefined, - $rdf.sym('http://www.w3.org/ns/solid/terms#account'), - undefined) - if (statements.length === 1) { - done() - } else { - done(new Error('missing link to WebID of account')) - } - }) - }) - }) - - it('should create a private settings container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/settings/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private prefs file in the settings container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/prefs.ttl') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private inbox container', function (done) { - var subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - }) -}) +// const supertest = require('supertest') +// // Helper functions for the FS +// const $rdf = require('rdflib') +// +// const { rm, read } = require('../test-utils') +// const ldnode = require('../../index') +// const fs = require('fs-extra') +// const path = require('path') +// +// describe('AccountManager (TLS account creation tests)', function () { +// this.timeout(10000) +// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +// +// var address = 'https://localhost:3457' +// var host = 'localhost:3457' +// var ldpHttpsServer +// let rootPath = path.join(__dirname, '../resources/accounts/') +// var ldp = ldnode.createServer({ +// root: rootPath, +// sslKey: path.join(__dirname, '../keys/key.pem'), +// sslCert: path.join(__dirname, '../keys/cert.pem'), +// auth: 'tls', +// webid: true, +// idp: true, +// strictOrigin: true +// }) +// +// before(function (done) { +// ldpHttpsServer = ldp.listen(3457, done) +// }) +// +// after(function () { +// if (ldpHttpsServer) ldpHttpsServer.close() +// fs.removeSync(path.join(rootPath, 'localhost/index.html')) +// fs.removeSync(path.join(rootPath, 'localhost/index.html.acl')) +// }) +// +// var server = supertest(address) +// +// it('should expect a 404 on GET /accounts', function (done) { +// server.get('/api/accounts') +// .expect(404, done) +// }) +// +// describe('accessing accounts', function () { +// it('should be able to access public file of an account', function (done) { +// var subdomain = supertest('https://tim.' + host) +// subdomain.get('/hello.html') +// .expect(200, done) +// }) +// it('should get 404 if root does not exist', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.get('/') +// .set('Accept', 'text/turtle') +// .set('Origin', 'http://example.com') +// .expect(404) +// .expect('Access-Control-Allow-Origin', 'http://example.com') +// .expect('Access-Control-Allow-Credentials', 'true') +// .end(function (err, res) { +// done(err) +// }) +// }) +// }) +// +// describe('generating a certificate', () => { +// beforeEach(function () { +// rm('accounts/nicola.localhost') +// }) +// after(function () { +// rm('accounts/nicola.localhost') +// }) +// +// it('should generate a certificate if spkac is valid', (done) => { +// var spkac = read('example_spkac.cnf') +// var subdomain = supertest.agent('https://nicola.' + host) +// subdomain.post('/api/accounts/new') +// .send('username=nicola&spkac=' + spkac) +// .expect('Content-Type', /application\/x-x509-user-cert/) +// .expect(200, done) +// }) +// +// it('should not generate a certificate if spkac is not valid', (done) => { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.post('/api/accounts/new') +// .send('username=nicola') +// .expect(200) +// .end((err) => { +// if (err) return done(err) +// +// subdomain.post('/api/accounts/cert') +// .send('username=nicola&spkac=') +// .expect(400, done) +// }) +// }) +// }) +// +// describe('creating an account with POST', function () { +// beforeEach(function () { +// rm('accounts/nicola.localhost') +// }) +// +// after(function () { +// rm('accounts/nicola.localhost') +// }) +// +// it('should not create WebID if no username is given', (done) => { +// let subdomain = supertest('https://nicola.' + host) +// let spkac = read('example_spkac.cnf') +// subdomain.post('/api/accounts/new') +// .send('username=&spkac=' + spkac) +// .expect(400, done) +// }) +// +// it('should not create a WebID if it already exists', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// let spkac = read('example_spkac.cnf') +// subdomain.post('/api/accounts/new') +// .send('username=nicola&spkac=' + spkac) +// .expect(200) +// .end((err) => { +// if (err) { +// return done(err) +// } +// subdomain.post('/api/accounts/new') +// .send('username=nicola&spkac=' + spkac) +// .expect(400) +// .end((err) => { +// done(err) +// }) +// }) +// }) +// +// it('should create the default folders', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// let spkac = read('example_spkac.cnf') +// subdomain.post('/api/accounts/new') +// .send('username=nicola&spkac=' + spkac) +// .expect(200) +// .end(function (err) { +// if (err) { +// return done(err) +// } +// var domain = host.split(':')[0] +// var card = read(path.join('accounts/nicola.' + domain, +// 'profile/card')) +// var cardAcl = read(path.join('accounts/nicola.' + domain, +// 'profile/card.acl')) +// var prefs = read(path.join('accounts/nicola.' + domain, +// 'settings/prefs.ttl')) +// var inboxAcl = read(path.join('accounts/nicola.' + domain, +// 'inbox/.acl')) +// var rootMeta = read(path.join('accounts/nicola.' + domain, '.meta')) +// var rootMetaAcl = read(path.join('accounts/nicola.' + domain, +// '.meta.acl')) +// +// if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && +// rootMetaAcl) { +// done() +// } else { +// done(new Error('failed to create default files')) +// } +// }) +// }) +// +// it('should link WebID to the root account', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// let spkac = read('example_spkac.cnf') +// subdomain.post('/api/accounts/new') +// .send('username=nicola&spkac=' + spkac) +// .expect(200) +// .end(function (err) { +// if (err) { +// return done(err) +// } +// subdomain.get('/.meta') +// .expect(200) +// .end(function (err, data) { +// if (err) { +// return done(err) +// } +// var graph = $rdf.graph() +// $rdf.parse( +// data.text, +// graph, +// 'https://nicola.' + host + '/.meta', +// 'text/turtle') +// var statements = graph.statementsMatching( +// undefined, +// $rdf.sym('http://www.w3.org/ns/solid/terms#account'), +// undefined) +// if (statements.length === 1) { +// done() +// } else { +// done(new Error('missing link to WebID of account')) +// } +// }) +// }) +// }) +// +// it('should create a private settings container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/settings/') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a private prefs file in the settings container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/inbox/prefs.ttl') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a private inbox container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/inbox/') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// }) +// }) diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc.js index f0dcc7d11..2cb86b36c 100644 --- a/test/integration/authentication-oidc.js +++ b/test/integration/authentication-oidc.js @@ -129,7 +129,7 @@ describe('Authentication API (OIDC)', () => { }) }) - describe('Login by Username and Password (POST /login)', () => { + describe('Login by Username and Password (POST /login/password)', () => { // Logging in as alice, to alice's pod let aliceAccount = UserAccount.from({ webId: aliceWebId }) let alicePassword = '12345' @@ -146,7 +146,7 @@ describe('Authentication API (OIDC)', () => { }) it('should login and be redirected to /authorize', (done) => { - alice.post('/login') + alice.post('/login/password') .type('form') .send({ username: 'alice' }) .send({ password: alicePassword }) @@ -160,21 +160,21 @@ describe('Authentication API (OIDC)', () => { }) it('should throw a 400 if no username is provided', (done) => { - alice.post('/login') + alice.post('/login/password') .type('form') .send({ password: alicePassword }) .expect(400, done) }) it('should throw a 400 if no password is provided', (done) => { - alice.post('/login') + alice.post('/login/password') .type('form') .send({ username: 'alice' }) .expect(400, done) }) it('should throw a 400 if user is found but no password match', (done) => { - alice.post('/login') + alice.post('/login/password') .type('form') .send({ username: 'alice' }) .send({ password: 'wrongpassword' }) diff --git a/test/unit/account-manager.js b/test/unit/account-manager.js index 7eb5e5aa4..fc7f57584 100644 --- a/test/unit/account-manager.js +++ b/test/unit/account-manager.js @@ -6,6 +6,7 @@ const expect = chai.expect const sinon = require('sinon') const sinonChai = require('sinon-chai') chai.use(sinonChai) +chai.use(require('dirty-chai')) chai.should() const rdf = require('rdflib') @@ -454,4 +455,26 @@ describe('AccountManager', () => { }) }) }) + + describe('externalAccount()', () => { + it('should return true if account is a subdomain of the local server url', () => { + let options = { host } + + let accountManager = AccountManager.from(options) + + let webId = 'https://alice.example.com/#me' + + expect(accountManager.externalAccount(webId)).to.be.false() + }) + + it('should return false if account does not match the local server url', () => { + let options = { host } + + let accountManager = AccountManager.from(options) + + let webId = 'https://alice.databox.me/#me' + + expect(accountManager.externalAccount(webId)).to.be.true() + }) + }) }) diff --git a/test/unit/auth-request.js b/test/unit/auth-request.js new file mode 100644 index 000000000..a434d83bb --- /dev/null +++ b/test/unit/auth-request.js @@ -0,0 +1,101 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +// const sinon = require('sinon') +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.should() +// const HttpMocks = require('node-mocks-http') +const url = require('url') + +const AuthRequest = require('../../lib/requests/auth-request') +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') +const UserAccount = require('../../lib/models/user-account') + +describe('AuthRequest', () => { + function testAuthQueryParams () { + let body = {} + body['response_type'] = 'code' + body['scope'] = 'openid' + body['client_id'] = 'client1' + body['redirect_uri'] = 'https://redirect.example.com/' + body['state'] = '1234' + body['nonce'] = '5678' + body['display'] = 'page' + + return body + } + + const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) + const accountManager = AccountManager.from({ host }) + + describe('extractAuthParams()', () => { + it('should initialize the auth url query object from params', () => { + let body = testAuthQueryParams() + body['other_key'] = 'whatever' + let req = { body, method: 'POST' } + + let extracted = AuthRequest.extractAuthParams(req) + + for (let param of AuthRequest.AUTH_QUERY_PARAMS) { + expect(extracted[param]).to.equal(body[param]) + } + + // make sure *only* the listed params were copied + expect(extracted['other_key']).to.not.exist() + }) + + it('should return empty params with no request body present', () => { + let req = { method: 'POST' } + + expect(AuthRequest.extractAuthParams(req)).to.eql({}) + }) + }) + + describe('authorizeUrl()', () => { + it('should return an /authorize url', () => { + let request = new AuthRequest({ accountManager }) + + let authUrl = request.authorizeUrl() + + expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true() + }) + + it('should pass through relevant auth query params from request body', () => { + let body = testAuthQueryParams() + let req = { body, method: 'POST' } + + let request = new AuthRequest({ accountManager }) + request.authQueryParams = AuthRequest.extractAuthParams(req) + + let authUrl = request.authorizeUrl() + + let parseQueryString = true + let parsedUrl = url.parse(authUrl, parseQueryString) + + for (let param in body) { + expect(body[param]).to.equal(parsedUrl.query[param]) + } + }) + }) + + describe('initUserSession()', () => { + it('should initialize the request session', () => { + let webId = 'https://alice.example.com/#me' + let alice = UserAccount.from({ username: 'alice', webId }) + let session = {} + + let request = new AuthRequest({ session }) + + request.initUserSession(alice) + + expect(request.session.userId).to.equal(webId) + expect(request.session.identified).to.be.true() + let subject = request.session.subject + expect(subject['_id']).to.equal(webId) + }) + }) +}) + diff --git a/test/unit/authenticator.js b/test/unit/authenticator.js new file mode 100644 index 000000000..83197675c --- /dev/null +++ b/test/unit/authenticator.js @@ -0,0 +1,34 @@ +'use strict' +const chai = require('chai') +const { expect } = chai +chai.use(require('chai-as-promised')) +chai.should() + +const { Authenticator } = require('../../lib/models/authenticator') + +describe('Authenticator', () => { + describe('constructor()', () => { + it('should initialize the accountManager property', () => { + let accountManager = {} + let auth = new Authenticator({ accountManager }) + + expect(auth.accountManager).to.equal(accountManager) + }) + }) + + describe('fromParams()', () => { + it('should throw an abstract method error', () => { + expect(() => Authenticator.fromParams()) + .to.throw(/Must override method/) + }) + }) + + describe('findValidUser()', () => { + it('should throw an abstract method error', () => { + let auth = new Authenticator({}) + + expect(() => auth.findValidUser()) + .to.throw(/Must override method/) + }) + }) +}) diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request.js index dcefcd804..e73c0244b 100644 --- a/test/unit/create-account-request.js +++ b/test/unit/create-account-request.js @@ -122,19 +122,6 @@ describe('CreateOidcAccountRequest', () => { expect(request.password).to.equal(aliceData.password) expect(request.userStore).to.equal(userStore) }) - - it('should throw an error if no password was given', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice', password: null } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, oidc: {}, accountManager } }, - body: aliceData, - session - }) - - expect(() => { CreateAccountRequest.fromParams(req, res) }) - .to.throw(/Password required/) - }) }) describe('saveCredentialsFor()', () => { diff --git a/test/unit/login-by-password-request.js b/test/unit/login-by-password-request.js deleted file mode 100644 index 413108c53..000000000 --- a/test/unit/login-by-password-request.js +++ /dev/null @@ -1,489 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() -const HttpMocks = require('node-mocks-http') -const url = require('url') - -const { - LoginByPasswordRequest -} = require('../../lib/requests/login-request') - -const UserAccount = require('../../lib/models/user-account') -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') - -const mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } -} - -const authMethod = 'oidc' -const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) -const accountManager = AccountManager.from({ host, authMethod }) - -describe('LoginByPasswordRequest', () => { - describe('post()', () => { - let res, req - - beforeEach(() => { - req = { - app: { locals: { oidc: { users: mockUserStore }, accountManager } }, - body: { username: 'alice', password: '12345' } - } - res = HttpMocks.createResponse() - }) - - it('should create a LoginRequest instance', () => { - let fromParams = sinon.spy(LoginByPasswordRequest, 'fromParams') - let loginStub = sinon.stub(LoginByPasswordRequest, 'login') - .returns(Promise.resolve()) - - return LoginByPasswordRequest.post(req, res) - .then(() => { - expect(fromParams).to.have.been.calledWith(req, res) - fromParams.reset() - loginStub.restore() - }) - .catch(error => { - fromParams.reset() - loginStub.restore() - throw error - }) - }) - - it('should invoke login()', () => { - let login = sinon.spy(LoginByPasswordRequest, 'login') - - return LoginByPasswordRequest.post(req, res) - .then(() => { - expect(login).to.have.been.called - login.reset() - }) - }) - }) - - describe('fromParams()', () => { - let session = {} - let userStore = {} - let req = { - session, - app: { locals: { oidc: { users: userStore }, accountManager } }, - body: { username: 'alice', password: '12345' } - } - let res = HttpMocks.createResponse() - - it('should return a LoginByPasswordRequest instance', () => { - let request = LoginByPasswordRequest.fromParams(req, res) - - expect(request.username).to.equal('alice') - expect(request.password).to.equal('12345') - expect(request.response).to.equal(res) - expect(request.session).to.equal(session) - expect(request.userStore).to.equal(userStore) - expect(request.accountManager).to.equal(accountManager) - }) - - it('should initialize the query params', () => { - let extractParams = sinon.spy(LoginByPasswordRequest, 'extractParams') - LoginByPasswordRequest.fromParams(req, res) - - expect(extractParams).to.be.calledWith(req) - }) - }) - - describe('login()', () => { - let userStore = mockUserStore - let response - - beforeEach(() => { - response = HttpMocks.createResponse() - }) - - it('should invoke validate()', () => { - let request = new LoginByPasswordRequest({ userStore, accountManager, response }) - - let validate = sinon.stub(request, 'validate') - - return LoginByPasswordRequest.login(request) - .then(() => { - expect(validate).to.have.been.called - }) - }) - - it('should call findValidUser()', () => { - let request = new LoginByPasswordRequest({ userStore, accountManager, response }) - request.validate = sinon.stub() - - let findValidUser = sinon.spy(request, 'findValidUser') - - return LoginByPasswordRequest.login(request) - .then(() => { - expect(findValidUser).to.have.been.called - }) - }) - - it('should call initUserSession() for a valid user', () => { - let validUser = {} - let request = new LoginByPasswordRequest({ userStore, accountManager, response }) - - request.validate = sinon.stub() - request.findValidUser = sinon.stub().returns(Promise.resolve(validUser)) - - let initUserSession = sinon.spy(request, 'initUserSession') - - return LoginByPasswordRequest.login(request) - .then(() => { - expect(initUserSession).to.have.been.calledWith(validUser) - }) - }) - - it('should call redirectPostLogin()', () => { - let validUser = {} - let request = new LoginByPasswordRequest({ userStore, accountManager, response }) - - request.validate = sinon.stub() - request.findValidUser = sinon.stub().returns(Promise.resolve(validUser)) - - let redirectPostLogin = sinon.spy(request, 'redirectPostLogin') - - return LoginByPasswordRequest.login(request) - .then(() => { - expect(redirectPostLogin).to.have.been.calledWith(validUser) - }) - }) - }) - - describe('validate()', () => { - it('should throw a 400 error if no username was provided', done => { - let options = { username: null, password: '12345' } - let request = new LoginByPasswordRequest(options) - - try { - request.validate() - } catch (error) { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('Username required') - done() - } - }) - - it('should throw a 400 error if no password was provided', done => { - let options = { username: 'alice', password: null } - let request = new LoginByPasswordRequest(options) - - try { - request.validate() - } catch (error) { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('Password required') - done() - } - }) - }) - - describe('findValidUser()', () => { - it('should throw a 400 if no valid user is found in the user store', done => { - let request = new LoginByPasswordRequest({ accountManager }) - - request.userStore = { - findUser: () => { return Promise.resolve(false) } - } - - request.findValidUser() - .catch(error => { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('No user found for that username') - done() - }) - }) - - it('should throw a 400 if user is found but password does not match', done => { - let request = new LoginByPasswordRequest({ accountManager }) - - request.userStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: () => { return Promise.resolve(false) } - } - - request.findValidUser() - .catch(error => { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('User found but no password match') - done() - }) - }) - - it('should return a valid user if one is found and password matches', () => { - let webId = 'https://alice.example.com/#me' - let validUser = { username: 'alice', webId } - let request = new LoginByPasswordRequest({ accountManager }) - - request.userStore = { - findUser: () => { return Promise.resolve(validUser) }, - matchPassword: (user, password) => { return Promise.resolve(user) } - } - - return request.findValidUser() - .then(foundUser => { - expect(foundUser.webId).to.equal(webId) - }) - }) - - describe('in Multi User mode', () => { - let multiUser = true - let serverUri = 'https://example.com' - let host = SolidHost.from({ serverUri }) - let accountManager = AccountManager.from({ multiUser, host }) - let mockUserStore - - beforeEach(() => { - let aliceRecord = { webId: 'https://alice.example.com/profile/card#me' } - - mockUserStore = { - findUser: sinon.stub().resolves(aliceRecord), - matchPassword: (user, password) => { return Promise.resolve(user) } - } - }) - - it('should load user from store if provided with username', () => { - let options = { username: 'alice', userStore: mockUserStore, accountManager } - let request = new LoginByPasswordRequest(options) - - let userStoreKey = 'alice.example.com/profile/card#me' - - return request.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - - it('should load user from store if provided with WebID', () => { - let webId = 'https://alice.example.com/profile/card#me' - let options = { username: webId, userStore: mockUserStore, accountManager } - let request = new LoginByPasswordRequest(options) - - let userStoreKey = 'alice.example.com/profile/card#me' - - return request.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - }) - - describe('in Single User mode', () => { - let multiUser = false - let serverUri = 'https://localhost:8443' - let host = SolidHost.from({ serverUri }) - let accountManager = AccountManager.from({ multiUser, host }) - let mockUserStore - - beforeEach(() => { - mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } - } - }) - - it('should load user from store if provided with username', () => { - let options = { username: 'alice', userStore: mockUserStore, accountManager } - let request = new LoginByPasswordRequest(options) - - let storeFindUser = sinon.spy(request.userStore, 'findUser') - let userStoreKey = 'localhost:8443/profile/card#me' - - return request.findValidUser() - .then(() => { - expect(storeFindUser).to.be.calledWith(userStoreKey) - }) - }) - - it('should load user from store if provided with WebID', () => { - let webId = 'https://localhost:8443/profile/card#me' - let options = { username: webId, userStore: mockUserStore, accountManager } - let request = new LoginByPasswordRequest(options) - - let storeFindUser = sinon.spy(request.userStore, 'findUser') - let userStoreKey = 'localhost:8443/profile/card#me' - - return request.findValidUser() - .then(() => { - expect(storeFindUser).to.be.calledWith(userStoreKey) - }) - }) - }) - }) - - describe('initUserSession()', () => { - it('should initialize the request session', () => { - let webId = 'https://alice.example.com/#me' - let alice = UserAccount.from({ username: 'alice', webId }) - let session = {} - - let request = new LoginByPasswordRequest({ session }) - - request.initUserSession(alice) - - expect(request.session.userId).to.equal(webId) - expect(request.session.identified).to.be.true - let subject = request.session.subject - expect(subject['_id']).to.equal(webId) - }) - }) - - function testAuthQueryParams () { - let body = {} - body['response_type'] = 'code' - body['scope'] = 'openid' - body['client_id'] = 'client1' - body['redirect_uri'] = 'https://redirect.example.com/' - body['state'] = '1234' - body['nonce'] = '5678' - body['display'] = 'page' - - return body - } - - describe('extractParams()', () => { - let body = testAuthQueryParams() - body['other_key'] = 'whatever' - let req = { body, method: 'POST' } - - it('should initialize the auth url query object from params', () => { - let extracted = LoginByPasswordRequest.extractParams(req) - - for (let param of LoginByPasswordRequest.AUTH_QUERY_PARAMS) { - expect(extracted[param]).to.equal(body[param]) - } - - // make sure *only* the listed params were copied - expect(extracted['other_key']).to.not.exist - }) - }) - - describe('authorizeUrl()', () => { - it('should return an /authorize url', () => { - let request = new LoginByPasswordRequest({ accountManager }) - - let authUrl = request.authorizeUrl() - - expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true - }) - - it('should pass through relevant auth query params from request body', () => { - let body = testAuthQueryParams() - let req = { body, method: 'POST' } - - let request = new LoginByPasswordRequest({ accountManager }) - request.authQueryParams = LoginByPasswordRequest.extractParams(req) - - let authUrl = request.authorizeUrl() - - let parseQueryString = true - let parsedUrl = url.parse(authUrl, parseQueryString) - - for (let param in body) { - expect(body[param]).to.equal(parsedUrl.query[param]) - } - }) - }) - - describe('redirectPostLogin()', () => { - it('should redirect to the /authorize url if redirect_uri is present', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let authQueryParams = { - redirect_uri: 'https://app.example.com/callback' - } - - let options = { accountManager, authQueryParams, response: res } - let request = new LoginByPasswordRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(authUrl) - }) - }) - - it('should redirect to account uri if no redirect_uri present', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let authQueryParams = {} - - let options = { accountManager, authQueryParams, response: res } - let request = new LoginByPasswordRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - let expectedUri = accountManager.accountUriFor('alice') - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - - it('should redirect to account uri if redirect_uri is string "undefined', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let body = { redirect_uri: 'undefined' } - - let options = { accountManager, response: res } - let request = new LoginByPasswordRequest(options) - request.authQueryParams = LoginByPasswordRequest.extractParams(body) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - let expectedUri = accountManager.accountUriFor('alice') - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - - describe('postRegisterUrl', () => { - it('should return encoded /authorize url if redirect_uri is present', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' - - let authQueryParams = { - redirect_uri: 'https://app.example.com/callback' - } - - let options = { accountManager, authQueryParams, response: res } - let request = new LoginByPasswordRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - let expectedAuthUrl = encodeURIComponent(authUrl) - - expect(request.postRegisterUrl()).to.equal(expectedAuthUrl) - }) - - it('should return encoded serverUri if not part of auth workflow', () => { - let res = HttpMocks.createResponse() - - let options = { accountManager, response: res } - let request = new LoginByPasswordRequest(options) - - let serverUri = 'https://localhost:8443' - let encodedServerUri = encodeURIComponent(serverUri) - - expect(request.postRegisterUrl()).to.equal(encodedServerUri) - }) - }) -}) diff --git a/test/unit/login-request.js b/test/unit/login-request.js new file mode 100644 index 000000000..b1a585af2 --- /dev/null +++ b/test/unit/login-request.js @@ -0,0 +1,238 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.should() +const HttpMocks = require('node-mocks-http') + +const AuthRequest = require('../../lib/requests/auth-request') +const { LoginRequest } = require('../../lib/requests/login-request') + +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const authMethod = 'oidc' +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host, authMethod }) +const localAuth = { password: true, tls: true } + +describe('LoginRequest', () => { + describe('loginPassword()', () => { + let res, req + + beforeEach(() => { + req = { + app: { locals: { oidc: { users: mockUserStore }, localAuth, accountManager } }, + body: { username: 'alice', password: '12345' } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + let fromParams = sinon.spy(LoginRequest, 'fromParams') + let loginStub = sinon.stub(LoginRequest, 'login') + .returns(Promise.resolve()) + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.reset() + loginStub.restore() + }) + }) + + it('should invoke login()', () => { + let login = sinon.spy(LoginRequest, 'login') + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(login).to.have.been.called() + login.reset() + }) + }) + }) + + describe('loginTls()', () => { + let res, req + + beforeEach(() => { + req = { + connection: {}, + app: { locals: { localAuth, accountManager } } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + return LoginRequest.loginTls(req, res) + .then(() => { + expect(LoginRequest.fromParams).to.have.been.calledWith(req, res) + LoginRequest.fromParams.reset() + LoginRequest.login.reset() + }) + }) + + it('should invoke login()', () => { + return LoginRequest.loginTls(req, res) + .then(() => { + expect(LoginRequest.login).to.have.been.called() + LoginRequest.login.reset() + }) + }) + }) + + describe('fromParams()', () => { + let session = {} + let req = { + session, + app: { locals: { accountManager } }, + body: { username: 'alice', password: '12345' } + } + let res = HttpMocks.createResponse() + + it('should return a LoginRequest instance', () => { + let request = LoginRequest.fromParams(req, res) + + expect(request.response).to.equal(res) + expect(request.session).to.equal(session) + expect(request.accountManager).to.equal(accountManager) + }) + + it('should initialize the query params', () => { + let requestOptions = sinon.spy(AuthRequest, 'requestOptions') + LoginRequest.fromParams(req, res) + + expect(requestOptions).to.have.been.calledWith(req) + }) + }) + + describe('login()', () => { + let userStore = mockUserStore + let response + + let options = { + userStore, + accountManager, + localAuth: {} + } + + beforeEach(() => { + response = HttpMocks.createResponse() + }) + + it('should call initUserSession() for a valid user', () => { + let validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + let request = new LoginRequest(options) + + let initUserSession = sinon.spy(request, 'initUserSession') + + return LoginRequest.login(request) + .then(() => { + expect(initUserSession).to.have.been.calledWith(validUser) + }) + }) + + it('should call redirectPostLogin()', () => { + let validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + let request = new LoginRequest(options) + + let redirectPostLogin = sinon.spy(request, 'redirectPostLogin') + + return LoginRequest.login(request) + .then(() => { + expect(redirectPostLogin).to.have.been.calledWith(validUser) + }) + }) + }) + + describe('postLoginUrl()', () => { + it('should return the user account uri if no redirect_uri param', () => { + let request = new LoginRequest({ authQueryParams: {} }) + + let aliceAccount = 'https://alice.example.com' + let user = { accountUri: aliceAccount } + + expect(request.postLoginUrl(user)).to.equal(aliceAccount) + }) + }) + + describe('redirectPostLogin()', () => { + it('should redirect to the /authorize url if redirect_uri is present', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost:8443/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) + + let authQueryParams = { + redirect_uri: 'https://app.example.com/callback' + } + + let options = { accountManager, authQueryParams, response: res } + let request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(authUrl) + }) + + it('should redirect to account uri if no redirect_uri present', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost/authorize?client_id=123' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) + + let authQueryParams = {} + + let options = { accountManager, authQueryParams, response: res } + let request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + let expectedUri = accountManager.accountUriFor('alice') + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + + it('should redirect to account uri if redirect_uri is string "undefined', () => { + let res = HttpMocks.createResponse() + let authUrl = 'https://localhost/authorize?client_id=123' + let validUser = accountManager.userAccountFrom({ username: 'alice' }) + + let body = { redirect_uri: 'undefined' } + + let options = { accountManager, response: res } + let request = new LoginRequest(options) + request.authQueryParams = AuthRequest.extractAuthParams({ body }) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + let expectedUri = accountManager.accountUriFor('alice') + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + }) +}) diff --git a/test/unit/password-authenticator.js b/test/unit/password-authenticator.js new file mode 100644 index 000000000..c93c094f0 --- /dev/null +++ b/test/unit/password-authenticator.js @@ -0,0 +1,228 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.should() + +const { PasswordAuthenticator } = require('../../lib/models/authenticator') + +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host }) + +describe('PasswordAuthenticator', () => { + describe('fromParams()', () => { + let req = { + body: { username: 'alice', password: '12345' } + } + let options = { userStore: mockUserStore, accountManager } + + it('should return a PasswordAuthenticator instance', () => { + let pwAuth = PasswordAuthenticator.fromParams(req, options) + + expect(pwAuth.userStore).to.equal(mockUserStore) + expect(pwAuth.accountManager).to.equal(accountManager) + expect(pwAuth.username).to.equal('alice') + expect(pwAuth.password).to.equal('12345') + }) + + it('should init with undefined username and password if no body is provided', () => { + let req = {} + + let pwAuth = PasswordAuthenticator.fromParams(req, {}) + + expect(pwAuth.username).to.be.undefined() + expect(pwAuth.password).to.be.undefined() + }) + }) + + describe('validate()', () => { + it('should throw a 400 error if no username was provided', done => { + let options = { username: null, password: '12345' } + let pwAuth = new PasswordAuthenticator(options) + + try { + pwAuth.validate() + } catch (error) { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('Username required') + done() + } + }) + + it('should throw a 400 error if no password was provided', done => { + let options = { username: 'alice', password: null } + let pwAuth = new PasswordAuthenticator(options) + + try { + pwAuth.validate() + } catch (error) { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('Password required') + done() + } + }) + }) + + describe('findValidUser()', () => { + it('should throw a 400 if no valid user is found in the user store', done => { + let options = { + username: 'alice', + password: '1234', + accountManager + } + let pwAuth = new PasswordAuthenticator(options) + + pwAuth.userStore = { + findUser: () => { return Promise.resolve(false) } + } + + pwAuth.findValidUser() + .catch(error => { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('No user found for that username') + done() + }) + }) + + it('should throw a 400 if user is found but password does not match', done => { + let options = { + username: 'alice', + password: '1234', + accountManager + } + let pwAuth = new PasswordAuthenticator(options) + + pwAuth.userStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: () => { return Promise.resolve(false) } + } + + pwAuth.findValidUser() + .catch(error => { + expect(error.statusCode).to.equal(400) + expect(error.message).to.equal('User found but no password match') + done() + }) + }) + + it('should return a valid user if one is found and password matches', () => { + let webId = 'https://alice.example.com/#me' + let validUser = { username: 'alice', webId } + let options = { + username: 'alice', + password: '1234', + accountManager + } + let pwAuth = new PasswordAuthenticator(options) + + pwAuth.userStore = { + findUser: () => { return Promise.resolve(validUser) }, + matchPassword: (user, password) => { return Promise.resolve(user) } + } + + return pwAuth.findValidUser() + .then(foundUser => { + expect(foundUser.webId).to.equal(webId) + }) + }) + + describe('in Multi User mode', () => { + let multiUser = true + let serverUri = 'https://example.com' + let host = SolidHost.from({ serverUri }) + + let accountManager = AccountManager.from({ multiUser, host }) + + let aliceRecord = { webId: 'https://alice.example.com/profile/card#me' } + let mockUserStore = { + findUser: sinon.stub().resolves(aliceRecord), + matchPassword: (user, password) => { return Promise.resolve(user) } + } + + it('should load user from store if provided with username', () => { + let options = { + username: 'alice', + password: '1234', + userStore: mockUserStore, + accountManager + } + let pwAuth = new PasswordAuthenticator(options) + + let userStoreKey = 'alice.example.com/profile/card#me' + + return pwAuth.findValidUser() + .then(() => { + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) + }) + }) + + it('should load user from store if provided with WebID', () => { + let webId = 'https://alice.example.com/profile/card#me' + let options = { + username: webId, + password: '1234', + userStore: mockUserStore, + accountManager + } + let pwAuth = new PasswordAuthenticator(options) + + let userStoreKey = 'alice.example.com/profile/card#me' + + return pwAuth.findValidUser() + .then(() => { + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) + }) + }) + }) + + describe('in Single User mode', () => { + let multiUser = false + let serverUri = 'https://localhost:8443' + let host = SolidHost.from({ serverUri }) + + let accountManager = AccountManager.from({ multiUser, host }) + + let aliceRecord = { webId: 'https://localhost:8443/profile/card#me' } + let mockUserStore = { + findUser: sinon.stub().resolves(aliceRecord), + matchPassword: (user, password) => { return Promise.resolve(user) } + } + + it('should load user from store if provided with username', () => { + let options = { username: 'admin', password: '1234', userStore: mockUserStore, accountManager } + let pwAuth = new PasswordAuthenticator(options) + + let userStoreKey = 'localhost:8443/profile/card#me' + + return pwAuth.findValidUser() + .then(() => { + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) + }) + }) + + it('should load user from store if provided with WebID', () => { + let webId = 'https://localhost:8443/profile/card#me' + let options = { username: webId, password: '1234', userStore: mockUserStore, accountManager } + let pwAuth = new PasswordAuthenticator(options) + + let userStoreKey = 'localhost:8443/profile/card#me' + + return pwAuth.findValidUser() + .then(() => { + expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) + }) + }) + }) + }) +}) diff --git a/test/unit/password-change-request.js b/test/unit/password-change-request.js index cd566b519..50943e273 100644 --- a/test/unit/password-change-request.js +++ b/test/unit/password-change-request.js @@ -255,7 +255,6 @@ describe('PasswordChangeRequest', () => { expect(response.render).to.have.been.calledWith('auth/change-password', { validToken: false, token, returnToUrl, error: 'error message' }) - expect(response.statusCode).to.equal(400) }) }) }) diff --git a/test/unit/tls-authenticator.js b/test/unit/tls-authenticator.js new file mode 100644 index 000000000..0ae6a3d2e --- /dev/null +++ b/test/unit/tls-authenticator.js @@ -0,0 +1,169 @@ +'use strict' + +const chai = require('chai') +const expect = chai.expect +const sinon = require('sinon') +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.use(require('chai-as-promised')) +chai.should() + +const { TlsAuthenticator } = require('../../lib/models/authenticator') + +const SolidHost = require('../../lib/models/solid-host') +const AccountManager = require('../../lib/models/account-manager') + +const host = SolidHost.from({ serverUri: 'https://example.com' }) +const accountManager = AccountManager.from({ host, multiUser: true }) + +describe('TlsAuthenticator', () => { + describe('fromParams()', () => { + let req = { + connection: {} + } + let options = { accountManager } + + it('should return a TlsAuthenticator instance', () => { + let tlsAuth = TlsAuthenticator.fromParams(req, options) + + expect(tlsAuth.accountManager).to.equal(accountManager) + expect(tlsAuth.connection).to.equal(req.connection) + }) + }) + + describe('findValidUser()', () => { + let webId = 'https://alice.example.com/#me' + let certificate = { uri: webId } + let connection = { + renegotiate: sinon.stub().yields(), + getPeerCertificate: sinon.stub().returns(certificate) + } + let options = { accountManager, connection } + + let tlsAuth = new TlsAuthenticator(options) + + tlsAuth.extractWebId = sinon.stub().resolves(webId) + sinon.spy(tlsAuth, 'renegotiateTls') + sinon.spy(tlsAuth, 'ensureLocalUser') + + return tlsAuth.findValidUser() + .then(validUser => { + expect(tlsAuth.renegotiateTls).to.have.been.called() + expect(connection.getPeerCertificate).to.have.been.called() + expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) + expect(tlsAuth.ensureLocalUser).to.have.been.calledWith(webId) + + expect(validUser.webId).to.equal(webId) + }) + }) + + describe('renegotiateTls()', () => { + it('should reject if an error occurs while renegotiating', () => { + let connection = { + renegotiate: sinon.stub().yields(new Error('Error renegotiating')) + } + + let tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.rejectedWith(/Error renegotiating/) + }) + + it('should resolve if no error occurs', () => { + let connection = { + renegotiate: sinon.stub().yields(null) + } + + let tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.fulfilled() + }) + }) + + describe('getCertificate()', () => { + it('should throw on a non-existent certificate', () => { + let connection = { + getPeerCertificate: sinon.stub().returns(null) + } + + let tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should throw on an empty certificate', () => { + let connection = { + getPeerCertificate: sinon.stub().returns({}) + } + + let tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should return a certificate if no error occurs', () => { + let certificate = { uri: 'https://alice.example.com/#me' } + let connection = { + getPeerCertificate: sinon.stub().returns(certificate) + } + + let tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.getCertificate()).to.equal(certificate) + }) + }) + + describe('extractWebId()', () => { + it('should reject if an error occurs verifying certificate', () => { + let tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId = sinon.stub().yields(new Error('Error processing certificate')) + + expect(tlsAuth.extractWebId()).to.be.rejectedWith(/Error processing certificate/) + }) + + it('should resolve with a verified web id', () => { + let tlsAuth = new TlsAuthenticator({}) + + let webId = 'https://alice.example.com/#me' + tlsAuth.verifyWebId = sinon.stub().yields(null, webId) + + let certificate = { uri: webId } + + expect(tlsAuth.extractWebId(certificate)).to.become(webId) + }) + }) + + describe('ensureLocalUser()', () => { + it('should throw an error if the user is not local to this server', () => { + let tlsAuth = new TlsAuthenticator({ accountManager }) + + let externalWebId = 'https://alice.someothersite.com#me' + + expect(() => tlsAuth.ensureLocalUser(externalWebId)) + .to.throw(/Cannot login: Selected Web ID is not hosted on this server/) + }) + + it('should return a user instance if the webid is local', () => { + let tlsAuth = new TlsAuthenticator({ accountManager }) + + let webId = 'https://alice.example.com/#me' + + let user = tlsAuth.ensureLocalUser(webId) + + expect(user.username).to.equal('alice') + expect(user.webId).to.equal(webId) + }) + }) + + describe('verifyWebId()', () => { + it('should yield an error if no cert is given', done => { + let tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId(null, (error) => { + expect(error.message).to.equal('No certificate given') + + done() + }) + }) + }) +}) From eca2d4bc718505780528d6ebe721f319ebad7ebe Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Wed, 7 Jun 2017 11:21:36 -0400 Subject: [PATCH 056/178] Fix 401 error handling, add tests (#507) * Add WWW-Authenticate to exposed CORS headers * Fix 401 error handling, add tests * Add a test for expired token error * Update to supertest 3.0.0 * Convert 401 tests to Promise interface * Rewrite error-pages handler, add tests * Change 401 error logic on empty bearer token * Return a 400 error on empty bearer token header --- lib/api/authn/webid-oidc.js | 74 +++++++- lib/api/authn/webid-tls.js | 23 ++- lib/create-app.js | 4 +- lib/handlers/error-pages.js | 220 +++++++++++++++++++----- package.json | 2 +- test/integration/acl-tls.js | 18 ++ test/integration/errors-oidc.js | 97 +++++++++++ test/integration/http.js | 4 +- test/resources/accounts/errortests/.acl | 16 ++ test/unit/auth-handlers.js | 103 +++++++++++ test/unit/error-pages.js | 127 ++++++++++++++ 11 files changed, 635 insertions(+), 53 deletions(-) create mode 100644 test/integration/errors-oidc.js create mode 100644 test/resources/accounts/errortests/.acl create mode 100644 test/unit/auth-handlers.js create mode 100644 test/unit/error-pages.js diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 6a67e616d..90ffc65c6 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -67,6 +67,78 @@ function middleware (oidc) { return router } +/** + * Sets the `WWW-Authenticate` response header for 401 error responses. + * Used by error-pages handler. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * @param err {Error} + */ +function setAuthenticateHeader (req, res, err) { + let locals = req.app.locals + + let errorParams = { + realm: locals.host.serverUri, + scope: 'openid', + error: err.error, + error_description: err.error_description, + error_uri: err.error_uri + } + + let challengeParams = Object.keys(errorParams) + .filter(key => !!errorParams[key]) + .map(key => `${key}="${errorParams[key]}"`) + .join(', ') + + res.set('WWW-Authenticate', 'Bearer ' + challengeParams) +} + +/** + * Provides custom logic for error status code overrides. + * + * @param statusCode {number} + * @param req {IncomingRequest} + * + * @returns {number} + */ +function statusCodeOverride (statusCode, req) { + if (isEmptyToken(req)) { + return 400 + } else { + return statusCode + } +} + +/** + * Tests whether the `Authorization:` header includes an empty or missing Bearer + * token. + * + * @param req {IncomingRequest} + * + * @returns {boolean} + */ +function isEmptyToken (req) { + let header = req.get('Authorization') + + if (!header) { return false } + + if (header.startsWith('Bearer')) { + let fragments = header.split(' ') + + if (fragments.length === 1) { + return true + } else if (!fragments[1]) { + return true + } + } + + return false +} + module.exports = { - middleware + isEmptyToken, + middleware, + setAuthenticateHeader, + statusCodeOverride } diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 4ccabc2fa..c63012407 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,6 +1,3 @@ -module.exports = handler -module.exports.authenticate = authenticate - var webid = require('webid/tls') var debug = require('../../debug').authentication @@ -43,3 +40,23 @@ function setEmptySession (req) { req.session.userId = '' req.session.identified = false } + +/** + * Sets the `WWW-Authenticate` response header for 401 error responses. + * Used by error-pages handler. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + */ +function setAuthenticateHeader (req, res) { + let locals = req.app.locals + + res.set('WWW-Authenticate', `WebID-TLS realm="${locals.host.serverUri}"`) +} + +module.exports = { + authenticate, + handler, + setAuthenticateHeader, + setEmptySession +} diff --git a/lib/create-app.js b/lib/create-app.js index 7e4ddd04f..50a06fdab 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -25,7 +25,7 @@ const corsSettings = cors({ methods: [ 'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE' ], - exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length', + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length, WWW-Authenticate', credentials: true, maxAge: 1728000, origin: true, @@ -71,7 +71,7 @@ function createApp (argv = {}) { app.use('/', LdpMiddleware(corsSettings)) // Errors - app.use(errorPages) + app.use(errorPages.handler) return app } diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index b1039f51a..f099caad3 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -1,53 +1,188 @@ -module.exports = handler +const debug = require('../debug').server +const fs = require('fs') +const util = require('../utils') +const Auth = require('../api/authn') -var debug = require('../debug').server -var fs = require('fs') -var util = require('../utils') +// Authentication methods that require a Provider Select page +const SELECT_PROVIDER_AUTH_METHODS = ['oidc'] +/** + * Serves as a last-stop error handler for all other middleware. + * + * @param err {Error} + * @param req {IncomingRequest} + * @param res {ServerResponse} + * @param next {Function} + */ function handler (err, req, res, next) { debug('Error page because of ' + err.message) - var ldp = req.app.locals.ldp - - if (err.status === 401 && - req.accepts('text/html') && - ldp.auth === 'oidc') { - debug('401 error - redirect to Select Provider') - res.status(err.status) - redirectToLogin(req, res, next) - return - } + let locals = req.app.locals + let authMethod = locals.authMethod + let ldp = locals.ldp - // If the user specifies this function - // then, they can customize the error programmatically + // If the user specifies this function, + // they can customize the error programmatically if (ldp.errorHandler) { + debug('Using custom error handler') return ldp.errorHandler(err, req, res, next) } + let statusCode = statusCodeFor(err, req, authMethod) + + if (statusCode === 401) { + setAuthenticateHeader(req, res, err) + } + + if (requiresSelectProvider(authMethod, statusCode, req)) { + return redirectToSelectProvider(req, res) + } + // If noErrorPages is set, - // then use built-in express default error handler + // then return the response directly if (ldp.noErrorPages) { - res - .status(err.status) - .send(err.message + '\n' || '') - return + sendErrorResponse(statusCode, res, err) + } else { + sendErrorPage(statusCode, res, err, ldp) + } +} + +/** + * Returns the HTTP status code for a given request error. + * + * @param err {Error} + * @param req {IncomingRequest} + * @param authMethod {string} + * + * @returns {number} + */ +function statusCodeFor (err, req, authMethod) { + let statusCode = err.status || err.statusCode || 500 + + if (authMethod === 'oidc') { + statusCode = Auth.oidc.statusCodeOverride(statusCode, req) } - // Check if error page exists - var errorPage = ldp.errorPages + err.status.toString() + '.html' - fs.readFile(errorPage, 'utf8', function (readErr, text) { - if (readErr) { - return res - .status(err.status) - .send(err.message || '') - } - - res.status(err.status) - res.header('Content-Type', 'text/html') - res.send(text) + return statusCode +} + +/** + * Tests whether a given authentication method requires a Select Provider + * page redirect for 401 error responses. + * + * @param authMethod {string} + * @param statusCode {number} + * @param req {IncomingRequest} + * + * @returns {boolean} + */ +function requiresSelectProvider (authMethod, statusCode, req) { + if (statusCode !== 401) { return false } + + if (!SELECT_PROVIDER_AUTH_METHODS.includes(authMethod)) { return false } + + if (!req.accepts('text/html')) { return false } + + return true +} + +/** + * Dispatches the writing of the `WWW-Authenticate` response header (used for + * 401 Unauthorized responses). + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + * @param err {Error} + */ +function setAuthenticateHeader (req, res, err) { + let locals = req.app.locals + let authMethod = locals.authMethod + + switch (authMethod) { + case 'oidc': + Auth.oidc.setAuthenticateHeader(req, res, err) + break + case 'tls': + Auth.tls.setAuthenticateHeader(req, res) + break + default: + break + } +} + +/** + * Sends the HTTP status code and error message in the response. + * + * @param statusCode {number} + * @param res {ServerResponse} + * @param err {Error} + */ +function sendErrorResponse (statusCode, res, err) { + res.status(statusCode) + res.send(err.message + '\n') +} + +/** + * Sends the HTTP status code and error message as a custom error page. + * + * @param statusCode {number} + * @param res {ServerResponse} + * @param err {Error} + * @param ldp {LDP} + */ +function sendErrorPage (statusCode, res, err, ldp) { + let errorPage = ldp.errorPages + statusCode.toString() + '.html' + + return new Promise((resolve) => { + fs.readFile(errorPage, 'utf8', (readErr, text) => { + if (readErr) { + // Fall back on plain error response + return resolve(sendErrorResponse(statusCode, res, err)) + } + + res.status(statusCode) + res.header('Content-Type', 'text/html') + res.send(text) + resolve() + }) }) } +/** + * Sends a 401 response with an HTML http-equiv type redirect body, to + * redirect any users requesting a resource directly in the browser to the + * Select Provider page and login workflow. + * Implemented as a 401 + redirect body instead of a 302 to provide a useful + * 401 response to REST/XHR clients. + * + * @param req {IncomingRequest} + * @param res {ServerResponse} + */ +function redirectToSelectProvider (req, res) { + res.status(401) + res.header('Content-Type', 'text/html') + + let currentUrl = util.fullUrlForReq(req) + req.session.returnToUrl = currentUrl + + let locals = req.app.locals + let loginUrl = locals.host.serverUri + + '/api/auth/select-provider?returnToUrl=' + currentUrl + debug('Redirecting to Select Provider: ' + loginUrl) + + let body = redirectBody(loginUrl) + res.send(body) +} + +/** + * Returns a response body for redirecting browsers to a Select Provider / + * login workflow page. Uses either a JS location.href redirect or an + * http-equiv type html redirect for no-script conditions. + * + * @param url {string} + * + * @returns {string} Response body + */ function redirectBody (url) { return ` @@ -63,15 +198,12 @@ follow the link to login ` } -function redirectToLogin (req, res) { - res.header('Content-Type', 'text/html') - var currentUrl = util.fullUrlForReq(req) - req.session.returnToUrl = currentUrl - let locals = req.app.locals - let loginUrl = locals.host.serverUri + - '/api/auth/select-provider?returnToUrl=' + currentUrl - debug('Redirecting to Select Provider: ' + loginUrl) - - var body = redirectBody(loginUrl) - res.send(body) +module.exports = { + handler, + redirectBody, + redirectToSelectProvider, + requiresSelectProvider, + sendErrorPage, + sendErrorResponse, + setAuthenticateHeader } diff --git a/package.json b/package.json index 0c47eb2ff..62ba3a954 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "sinon": "^2.1.0", "sinon-chai": "^2.8.0", "standard": "^8.6.0", - "supertest": "^1.2.0" + "supertest": "^3.0.0" }, "main": "index.js", "scripts": { diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 26f5a2167..4cf201707 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -97,11 +97,29 @@ describe('ACL HTTP', function () { done() }) }) + it('should have `User` set in the Response Header', function (done) { var options = createOptions('/acl-tls/no-acl/', 'user1') request(options, function (error, response, body) { assert.equal(error, null) assert.equal(response.statusCode, 403) + assert.equal(response.headers['user'], + 'https://user1.databox.me/profile/card#me') + done() + }) + }) + + it('should return a 401 and WWW-Authenticate header without credentials', (done) => { + let options = { + url: address + '/acl-tls/no-acl/', + headers: { accept: 'text/turtle' } + } + + request(options, (error, response, body) => { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.headers['www-authenticate'], + 'WebID-TLS realm="https://localhost:8443"') done() }) }) diff --git a/test/integration/errors-oidc.js b/test/integration/errors-oidc.js new file mode 100644 index 000000000..2ee70de84 --- /dev/null +++ b/test/integration/errors-oidc.js @@ -0,0 +1,97 @@ +const supertest = require('supertest') +const ldnode = require('../../index') +const path = require('path') +const fs = require('fs-extra') +const expect = require('chai').expect + +describe('OIDC error handling', function () { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + const serverUri = 'https://localhost:3457' + var ldpHttpsServer + const rootPath = path.join(__dirname, '../resources/accounts/errortests') + const dbPath = path.join(__dirname, '../resources/accounts/db') + + const ldp = ldnode.createServer({ + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'oidc', + webid: true, + idp: false, + strictOrigin: true, + dbPath, + serverUri + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(3457, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'index.html')) + fs.removeSync(path.join(rootPath, 'index.html.acl')) + }) + + const server = supertest(serverUri) + + describe('Unauthenticated requests to protected resources', () => { + describe('accepting text/html', () => { + it('should return 401 Unauthorized with www-auth header', () => { + return server.get('/profile/') + .set('Accept', 'text/html') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid"') + .expect(401) + }) + + it('should return an html redirect body', () => { + return server.get('/profile/') + .set('Accept', 'text/html') + .expect('Content-Type', 'text/html; charset=utf-8') + .then(res => { + expect(res.text).to.match(/ { + describe('with an empty bearer token', () => { + it('should return a 400 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer ') + .expect(400) + }) + }) + + describe('with an invalid bearer token', () => { + it('should return a 401 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer abcd123') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid", error="invalid_token", error_description="Access token is not a JWT"') + .expect(401) + }) + }) + + describe('with an expired bearer token', () => { + const expiredToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxOWk9CLURQRTFrIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3Iiwic3ViIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6MzQ1Ny9wcm9maWxlL2NhcmQjbWUiLCJhdWQiOiJodHRwczovL2xvY2FsaG9zdDozNDU3IiwiZXhwIjoxNDk2MjM5ODY1LCJpYXQiOjE0OTYyMzk4NjUsImp0aSI6IjliN2MwNGQyNDY3MjQ1ZWEiLCJub25jZSI6IklXaUpMVFNZUmktVklSSlhjejVGdU9CQTFZR1lZNjFnRGRlX2JnTEVPMDAiLCJhdF9oYXNoIjoiRFpES3I0RU1xTGE1Q0x1elV1WW9pdyJ9.uBTLy_wG5rr4kxM0hjXwIC-NwGYrGiiiY9IdOk5hEjLj2ECc767RU7iZ5vZa0pSrGy0V2Y3BiZ7lnYIA7N4YUAuS077g_4zavoFWyu9xeq6h70R8yfgFUNPo91PGpODC9hgiNbEv2dPBzTYYHqf7D6_-3HGnnDwiX7TjWLTkPLRvPLTcsCUl7G7y-EedjcVRk3Jyv8TNSoBMeTwOR3ewuzNostmCjUuLsr73YpVid6HE55BBqgSCDCNtS-I7nYmO_lRqIWJCydjdStSMJgxzSpASvoeCJ_lwZF6FXmZOQNNhmstw69fU85J1_QsS78cRa76-SnJJp6JCWHFBUAolPQ' + + it('should return a 401 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer ' + expiredToken) + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid", error="invalid_token", error_description="Access token is expired."') + .expect(401) + }) + }) + }) +}) diff --git a/test/integration/http.js b/test/integration/http.js index ba4dc8098..c81e79167 100644 --- a/test/integration/http.js +++ b/test/integration/http.js @@ -57,7 +57,7 @@ function createTestResource (resourceName) { describe('HTTP APIs', function () { var emptyResponse = function (res) { - if (res.text.length !== 0) { + if (res.text) { console.log('Not empty response') } } @@ -106,7 +106,7 @@ describe('HTTP APIs', function () { .expect('Access-Control-Allow-Origin', 'http://example.com') .expect('Access-Control-Allow-Credentials', 'true') .expect('Access-Control-Allow-Methods', 'OPTIONS,HEAD,GET,PATCH,POST,PUT,DELETE') - .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length') + .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length, WWW-Authenticate') .expect(204, done) }) diff --git a/test/resources/accounts/errortests/.acl b/test/resources/accounts/errortests/.acl new file mode 100644 index 000000000..f7be11c66 --- /dev/null +++ b/test/resources/accounts/errortests/.acl @@ -0,0 +1,16 @@ +@prefix acl: . + +<#owner> + a acl:Authorization; + + acl:agent ; + + # Set the access to the root storage folder itself + acl:accessTo ; + + # All resources will inherit this authorization, by default + acl:defaultForNew ; + + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. diff --git a/test/unit/auth-handlers.js b/test/unit/auth-handlers.js new file mode 100644 index 000000000..a55912fb2 --- /dev/null +++ b/test/unit/auth-handlers.js @@ -0,0 +1,103 @@ +'use strict' +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.should() + +const Auth = require('../../lib/api/authn') + +describe('OIDC Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + }, + get: sinon.stub() + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header with error params', () => { + let error = { + error: 'invalid_token', + error_description: 'Invalid token', + error_uri: 'https://example.com/errors/token' + } + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' + ) + }) + + it('should set WWW-Authenticate with no error_description if none given', () => { + let error = {} + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid"' + ) + }) + }) + + describe('isEmptyToken()', () => { + let req + + beforeEach(() => { + req = { get: sinon.stub() } + }) + + it('should be true for empty access token', () => { + req.get.withArgs('Authorization').returns('Bearer ') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + + req.get.withArgs('Authorization').returns('Bearer') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + }) + + it('should be false when access token is present', () => { + req.get.withArgs('Authorization').returns('Bearer token123') + + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + + it('should be false when no authorization header is present', () => { + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + }) +}) + +describe('WebID-TLS Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + } + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header', () => { + Auth.tls.setAuthenticateHeader(req, res) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'WebID-TLS realm="https://example.com"' + ) + }) + }) +}) diff --git a/test/unit/error-pages.js b/test/unit/error-pages.js new file mode 100644 index 000000000..3444dcd1e --- /dev/null +++ b/test/unit/error-pages.js @@ -0,0 +1,127 @@ +'use strict' +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai +chai.use(require('sinon-chai')) +chai.use(require('dirty-chai')) +chai.should() + +const errorPages = require('../../lib/handlers/error-pages') + +describe('handlers/error-pages', () => { + describe('handler()', () => { + it('should use the custom error handler if available', () => { + let ldp = { errorHandler: sinon.stub() } + let req = { app: { locals: { ldp } } } + let res = { status: sinon.stub(), send: sinon.stub() } + let err = {} + let next = {} + + errorPages.handler(err, req, res, next) + + expect(ldp.errorHandler).to.have.been.calledWith(err, req, res, next) + + expect(res.status).to.not.have.been.called() + expect(res.send).to.not.have.been.called() + }) + + it('defaults to status code 500 if none is specified in the error', () => { + let ldp = { noErrorPages: true } + let req = { app: { locals: { ldp } } } + let res = { status: sinon.stub(), send: sinon.stub() } + let err = { message: 'Unspecified error' } + let next = {} + + errorPages.handler(err, req, res, next) + + expect(res.status).to.have.been.calledWith(500) + expect(res.send).to.have.been.calledWith('Unspecified error\n') + }) + }) + + describe('requiresSelectProvider()', () => { + it('should only apply to 401 error codes', () => { + let authMethod = 'oidc' + let req = { accepts: sinon.stub().withArgs('text/html').returns(true) } + + expect(errorPages.requiresSelectProvider(authMethod, 404, req)) + .to.equal(false) + expect(errorPages.requiresSelectProvider(authMethod, 401, req)) + .to.equal(true) + }) + + it('should only apply to oidc auth method', () => { + let statusCode = 401 + let req = { accepts: sinon.stub().withArgs('text/html').returns(true) } + + expect(errorPages.requiresSelectProvider('tls', statusCode, req)) + .to.equal(false) + expect(errorPages.requiresSelectProvider('oidc', statusCode, req)) + .to.equal(true) + }) + + it('should only apply to html requests', () => { + let authMethod = 'oidc' + let statusCode = 401 + let htmlReq = { accepts: sinon.stub().withArgs('text/html').returns(true) } + let nonHtmlReq = { accepts: sinon.stub().withArgs('text/html').returns(false) } + + expect(errorPages.requiresSelectProvider(authMethod, statusCode, nonHtmlReq)) + .to.equal(false) + expect(errorPages.requiresSelectProvider(authMethod, statusCode, htmlReq)) + .to.equal(true) + }) + }) + + describe('sendErrorResponse()', () => { + it('should send http status code and error message', () => { + let statusCode = 404 + let error = { + message: 'Error description' + } + let res = { + status: sinon.stub(), + send: sinon.stub() + } + + errorPages.sendErrorResponse(statusCode, res, error) + + expect(res.status).to.have.been.calledWith(404) + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + + describe('setAuthenticateHeader()', () => { + it('should do nothing for a non-implemented auth method', () => { + let err = {} + let req = { + app: { locals: { authMethod: null } } + } + let res = { + set: sinon.stub() + } + + errorPages.setAuthenticateHeader(req, res, err) + + expect(res.set).to.not.have.been.called() + }) + }) + + describe('sendErrorPage()', () => { + it('falls back the default sendErrorResponse if no page is found', () => { + let statusCode = 400 + let res = { + status: sinon.stub(), + send: sinon.stub() + } + let err = { message: 'Error description' } + let ldp = { errorPages: './' } + + return errorPages.sendErrorPage(statusCode, res, err, ldp) + .then(() => { + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + }) +}) From 3659b65f6369eb901dc408575caf2796858ce8e0 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 22 Jun 2017 17:11:35 -0400 Subject: [PATCH 057/178] Make ./data the default root folder (#510) * Make ./data the default root folder * Add ./data to .gitignore * Fix typo in help text --- .gitignore | 3 +-- bin/lib/options.js | 4 ++-- data/.gitkeep | 0 3 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 data/.gitkeep diff --git a/.gitignore b/.gitignore index 0a0e9544a..e1897e4cb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ npm-debug.log /.db .nyc_output coverage -/index.html -/index.html.acl +/data \ No newline at end of file diff --git a/bin/lib/options.js b/bin/lib/options.js index a202f8cba..1e882581c 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -10,9 +10,9 @@ module.exports = [ // }, { name: 'root', - help: "Root folder to serve (defaut: './')", + help: "Root folder to serve (default: './data')", question: 'Path to the folder you want to serve. Default is', - default: './', + default: './data', prompt: true, filter: (value) => path.resolve(value) }, diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 000000000..e69de29bb From 0b03de7d3cf4d6097ca68976762a6b448397a459 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 22 Jun 2017 15:45:22 -0400 Subject: [PATCH 058/178] Move patch handlers to separate files. The patch handler contains logic for multiple request body types. Moving them to separate files facilitates adding support for more types. --- lib/handlers/patch.js | 144 +------------------- lib/handlers/patch/sparql-patcher.js | 74 ++++++++++ lib/handlers/patch/sparql-update-patcher.js | 80 +++++++++++ 3 files changed, 158 insertions(+), 140 deletions(-) create mode 100644 lib/handlers/patch/sparql-patcher.js create mode 100644 lib/handlers/patch/sparql-update-patcher.js diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 24b10e6e7..7abb0829a 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -1,12 +1,11 @@ module.exports = handler var mime = require('mime-types') -var fs = require('fs') -var $rdf = require('rdflib') var debug = require('../debug').handlers var utils = require('../utils.js') var error = require('../http-error') -const waterfall = require('run-waterfall') +const sparqlPatch = require('./patch/sparql-patcher.js') +const sparqlUpdatePatch = require('./patch/sparql-update-patcher.js') const DEFAULT_CONTENT_TYPE = 'text/turtle' @@ -39,7 +38,7 @@ function patchHandler (req, res, next) { debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>') if (patchContentType === 'application/sparql') { - sparql(filename, targetURI, req.text, function (err, result) { + sparqlPatch(filename, targetURI, req.text, function (err, result) { if (err) { return next(err) } @@ -47,7 +46,7 @@ function patchHandler (req, res, next) { return next() }) } else if (patchContentType === 'application/sparql-update') { - return sparqlUpdate(filename, targetURI, req.text, function (err, patchKB) { + return sparqlUpdatePatch(filename, targetURI, req.text, function (err, patchKB) { if (err) { return next(err) } @@ -61,138 +60,3 @@ function patchHandler (req, res, next) { return next(error(400, 'Unknown patch content type: ' + patchContentType)) } } // postOrPatch - -function sparql (filename, targetURI, text, callback) { - debug('PATCH -- parsing query ...') - var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - var patchKB = $rdf.graph() - var targetKB = $rdf.graph() - var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE - var query = $rdf.SPARQLToQuery(text, false, patchKB, patchURI) // last param not used ATM - - fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { - if (err) { - return callback(error(404, 'Patch: Original file read error:' + err)) - } - - debug('PATCH -- File read OK ' + dataIn.length) - debug('PATCH -- parsing target file ...') - - try { - $rdf.parse(dataIn, targetKB, targetURI, targetContentType) - } catch (e) { - debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) - return callback(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) - } - debug('PATCH -- Target parsed OK ') - - var bindingsArray = [] - - var onBindings = function (bindings) { - var b = {} - var v - var x - for (v in bindings) { - if (bindings.hasOwnProperty(v)) { - x = bindings[v] - b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value } - if (x.lang) { - b[v]['xml:lang'] = x.lang - } - if (x.dt) { - b[v].dt = x.dt.uri // @@@ Correct? @@ check - } - } - } - debug('PATCH -- bindings: ' + JSON.stringify(b)) - bindingsArray.push(b) - } - - var onDone = function () { - debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) - return callback(null, { - 'head': { - 'vars': query.vars.map(function (v) { - return v.toNT() - }) - }, - 'results': { - 'bindings': bindingsArray - } - }) - } - - var fetcher = new $rdf.Fetcher(targetKB, 10000, true) - targetKB.query(query, onBindings, fetcher, onDone) - }) -} - -function sparqlUpdate (filename, targetURI, text, callback) { - var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - var patchKB = $rdf.graph() - var targetKB = $rdf.graph() - var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE - - debug('PATCH -- parsing patch ...') - var patchObject - try { - // Must parse relative to document's base address but patch doc should get diff URI - patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI) - } catch (e) { - return callback(error(400, 'Patch format syntax error:\n' + e + '\n')) - } - debug('PATCH -- reading target file ...') - - waterfall([ - (cb) => { - fs.stat(filename, (err) => { - if (!err) return cb() - - fs.writeFile(filename, '', (err) => { - if (err) { - return cb(error(err, 'Error creating the patch target')) - } - cb() - }) - }) - }, - (cb) => { - fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { - if (err) { - return cb(error(500, 'Error reading the patch target')) - } - - debug('PATCH -- target read OK ' + dataIn.length + ' bytes. Parsing...') - - try { - $rdf.parse(dataIn, targetKB, targetURI, targetContentType) - } catch (e) { - debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) - return cb(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) - } - - var target = patchKB.sym(targetURI) - debug('PATCH -- Target parsed OK, patching... ') - - targetKB.applyPatch(patchObject, target, function (err) { - if (err) { - var message = err.message || err // returns string at the moment - debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return cb(error(409, 'Error when applying the patch')) - } - debug('PATCH -- Patched. Writeback URI base ' + targetURI) - var data = $rdf.serialize(target, targetKB, targetURI, targetContentType) - // debug('Writeback data: ' + data) - - fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) { - if (err) { - return cb(error(500, 'Failed to write file back after patch: ' + err)) - } - debug('PATCH -- applied OK (sync)') - return cb(null, patchKB) - }) - }) - }) - } - ], callback) -} diff --git a/lib/handlers/patch/sparql-patcher.js b/lib/handlers/patch/sparql-patcher.js new file mode 100644 index 000000000..eb4768cba --- /dev/null +++ b/lib/handlers/patch/sparql-patcher.js @@ -0,0 +1,74 @@ +module.exports = patch + +var mime = require('mime-types') +var fs = require('fs') +var $rdf = require('rdflib') +var debug = require('../../debug').handlers +var error = require('../../http-error') + +const DEFAULT_CONTENT_TYPE = 'text/turtle' + +function patch (filename, targetURI, text, callback) { + debug('PATCH -- parsing query ...') + var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place + var patchKB = $rdf.graph() + var targetKB = $rdf.graph() + var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE + var query = $rdf.SPARQLToQuery(text, false, patchKB, patchURI) // last param not used ATM + + fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { + if (err) { + return callback(error(404, 'Patch: Original file read error:' + err)) + } + + debug('PATCH -- File read OK ' + dataIn.length) + debug('PATCH -- parsing target file ...') + + try { + $rdf.parse(dataIn, targetKB, targetURI, targetContentType) + } catch (e) { + debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) + return callback(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) + } + debug('PATCH -- Target parsed OK ') + + var bindingsArray = [] + + var onBindings = function (bindings) { + var b = {} + var v + var x + for (v in bindings) { + if (bindings.hasOwnProperty(v)) { + x = bindings[v] + b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value } + if (x.lang) { + b[v]['xml:lang'] = x.lang + } + if (x.dt) { + b[v].dt = x.dt.uri // @@@ Correct? @@ check + } + } + } + debug('PATCH -- bindings: ' + JSON.stringify(b)) + bindingsArray.push(b) + } + + var onDone = function () { + debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) + return callback(null, { + 'head': { + 'vars': query.vars.map(function (v) { + return v.toNT() + }) + }, + 'results': { + 'bindings': bindingsArray + } + }) + } + + var fetcher = new $rdf.Fetcher(targetKB, 10000, true) + targetKB.query(query, onBindings, fetcher, onDone) + }) +} diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js new file mode 100644 index 000000000..1b6197dc3 --- /dev/null +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -0,0 +1,80 @@ +module.exports = patch + +var mime = require('mime-types') +var fs = require('fs') +var $rdf = require('rdflib') +var debug = require('../../debug').handlers +var error = require('../../http-error') +const waterfall = require('run-waterfall') + +const DEFAULT_CONTENT_TYPE = 'text/turtle' + +function patch (filename, targetURI, text, callback) { + var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place + var patchKB = $rdf.graph() + var targetKB = $rdf.graph() + var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE + + debug('PATCH -- parsing patch ...') + var patchObject + try { + // Must parse relative to document's base address but patch doc should get diff URI + patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI) + } catch (e) { + return callback(error(400, 'Patch format syntax error:\n' + e + '\n')) + } + debug('PATCH -- reading target file ...') + + waterfall([ + (cb) => { + fs.stat(filename, (err) => { + if (!err) return cb() + + fs.writeFile(filename, '', (err) => { + if (err) { + return cb(error(err, 'Error creating the patch target')) + } + cb() + }) + }) + }, + (cb) => { + fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { + if (err) { + return cb(error(500, 'Error reading the patch target')) + } + + debug('PATCH -- target read OK ' + dataIn.length + ' bytes. Parsing...') + + try { + $rdf.parse(dataIn, targetKB, targetURI, targetContentType) + } catch (e) { + debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) + return cb(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) + } + + var target = patchKB.sym(targetURI) + debug('PATCH -- Target parsed OK, patching... ') + + targetKB.applyPatch(patchObject, target, function (err) { + if (err) { + var message = err.message || err // returns string at the moment + debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') + return cb(error(409, 'Error when applying the patch')) + } + debug('PATCH -- Patched. Writeback URI base ' + targetURI) + var data = $rdf.serialize(target, targetKB, targetURI, targetContentType) + // debug('Writeback data: ' + data) + + fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) { + if (err) { + return cb(error(500, 'Failed to write file back after patch: ' + err)) + } + debug('PATCH -- applied OK (sync)') + return cb(null, patchKB) + }) + }) + }) + } + ], callback) +} From a79d19d2885f08f96d0befcdfee83242b8279d8a Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 22 Jun 2017 16:08:58 -0400 Subject: [PATCH 059/178] Use same patch logic regardless of content type. --- lib/handlers/patch.js | 39 +++++++++------------ lib/handlers/patch/sparql-patcher.js | 5 +-- lib/handlers/patch/sparql-update-patcher.js | 2 +- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 7abb0829a..bfec6f085 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -4,11 +4,14 @@ var mime = require('mime-types') var debug = require('../debug').handlers var utils = require('../utils.js') var error = require('../http-error') -const sparqlPatch = require('./patch/sparql-patcher.js') -const sparqlUpdatePatch = require('./patch/sparql-update-patcher.js') const DEFAULT_CONTENT_TYPE = 'text/turtle' +const PATCHERS = { + 'application/sparql': require('./patch/sparql-patcher.js'), + 'application/sparql-update': require('./patch/sparql-update-patcher.js') +} + function handler (req, res, next) { req.setEncoding('utf8') req.text = '' @@ -37,26 +40,16 @@ function patchHandler (req, res, next) { debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>') - if (patchContentType === 'application/sparql') { - sparqlPatch(filename, targetURI, req.text, function (err, result) { - if (err) { - return next(err) - } - res.json(result) - return next() - }) - } else if (patchContentType === 'application/sparql-update') { - return sparqlUpdatePatch(filename, targetURI, req.text, function (err, patchKB) { - if (err) { - return next(err) - } - - // subscription.publishDelta(req, res, patchKB, targetURI) - debug('PATCH -- applied OK (sync)') - res.send('Patch applied OK\n') - return next() - }) - } else { + const patch = PATCHERS[patchContentType] + if (!patch) { return next(error(400, 'Unknown patch content type: ' + patchContentType)) } -} // postOrPatch + patch(filename, targetURI, req.text, function (err, result) { + if (err) { + next(err) + } else { + res.send(result) + next() + } + }) +} diff --git a/lib/handlers/patch/sparql-patcher.js b/lib/handlers/patch/sparql-patcher.js index eb4768cba..8706d1e9b 100644 --- a/lib/handlers/patch/sparql-patcher.js +++ b/lib/handlers/patch/sparql-patcher.js @@ -56,7 +56,7 @@ function patch (filename, targetURI, text, callback) { var onDone = function () { debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) - return callback(null, { + const result = { 'head': { 'vars': query.vars.map(function (v) { return v.toNT() @@ -65,7 +65,8 @@ function patch (filename, targetURI, text, callback) { 'results': { 'bindings': bindingsArray } - }) + } + callback(null, JSON.stringify(result)) } var fetcher = new $rdf.Fetcher(targetKB, 10000, true) diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index 1b6197dc3..d98b857ae 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -71,7 +71,7 @@ function patch (filename, targetURI, text, callback) { return cb(error(500, 'Failed to write file back after patch: ' + err)) } debug('PATCH -- applied OK (sync)') - return cb(null, patchKB) + return cb(null, 'Patch applied OK\n') }) }) }) From acc71515bf04ea6871b26cb903bccbb84c3407d7 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 22 Jun 2017 16:11:00 -0400 Subject: [PATCH 060/178] Use "415 Unsupported Media Type" for unsupported patches. --- lib/handlers/patch.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index bfec6f085..7f6f5e2bf 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -42,7 +42,7 @@ function patchHandler (req, res, next) { const patch = PATCHERS[patchContentType] if (!patch) { - return next(error(400, 'Unknown patch content type: ' + patchContentType)) + return next(error(415, 'Unknown patch content type: ' + patchContentType)) } patch(filename, targetURI, req.text, function (err, result) { if (err) { From f55a2a069739668d01943f24ec249a74def2cb71 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 12:31:22 -0400 Subject: [PATCH 061/178] Deduplicate graph reading code. --- lib/handlers/patch.js | 50 ++++++++++- lib/handlers/patch/sparql-patcher.js | 92 ++++++++------------- lib/handlers/patch/sparql-update-patcher.js | 73 +++++----------- 3 files changed, 99 insertions(+), 116 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 7f6f5e2bf..de644e8dd 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -1,9 +1,11 @@ module.exports = handler var mime = require('mime-types') +var fs = require('fs') var debug = require('../debug').handlers var utils = require('../utils.js') var error = require('../http-error') +var $rdf = require('rdflib') const DEFAULT_CONTENT_TYPE = 'text/turtle' @@ -40,16 +42,56 @@ function patchHandler (req, res, next) { debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>') + // Obtain a patcher for the given patch type const patch = PATCHERS[patchContentType] if (!patch) { return next(error(415, 'Unknown patch content type: ' + patchContentType)) } - patch(filename, targetURI, req.text, function (err, result) { - if (err) { - next(err) - } else { + + // Read the RDF graph to be patched + readGraph(filename, targetURI).then((targetKB) => { + // Patch the target graph + patch(targetKB, filename, targetURI, req.text, function (err, result) { + if (err) { + throw err + } res.send(result) next() + }) + }) + .catch(next) +} + +// Reads the RDF graph in the given file with the corresponding URI +function readGraph (resourceFile, resourceURI) { + // Read the file + return new Promise((resolve, reject) => { + fs.readFile(resourceFile, {encoding: 'utf8'}, function (err, fileContents) { + if (err) { + // If the file does not exist, assume empty contents + // (it will be created after a successful patch) + if (err.code === 'ENOENT') { + fileContents = '' + // Fail on all other errors + } else { + return reject(error(500, 'Patch: Original file read error:' + err)) + } + } + debug('PATCH -- Read target file (%d bytes)', fileContents.length) + resolve(fileContents) + }) + }) + // Parse the file + .then((fileContents) => { + const graph = $rdf.graph() + const contentType = mime.lookup(resourceFile) || DEFAULT_CONTENT_TYPE + debug('PATCH -- Reading %s with content type %s', resourceURI, contentType) + try { + $rdf.parse(fileContents, graph, resourceURI, contentType) + } catch (err) { + throw error(500, 'Patch: Target ' + contentType + ' file syntax error:' + err) } + debug('PATCH -- Parsed target file') + return graph }) } diff --git a/lib/handlers/patch/sparql-patcher.js b/lib/handlers/patch/sparql-patcher.js index 8706d1e9b..171309535 100644 --- a/lib/handlers/patch/sparql-patcher.js +++ b/lib/handlers/patch/sparql-patcher.js @@ -1,75 +1,51 @@ module.exports = patch -var mime = require('mime-types') -var fs = require('fs') var $rdf = require('rdflib') var debug = require('../../debug').handlers -var error = require('../../http-error') -const DEFAULT_CONTENT_TYPE = 'text/turtle' - -function patch (filename, targetURI, text, callback) { +function patch (targetKB, filename, targetURI, text, callback) { debug('PATCH -- parsing query ...') var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place var patchKB = $rdf.graph() - var targetKB = $rdf.graph() - var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE var query = $rdf.SPARQLToQuery(text, false, patchKB, patchURI) // last param not used ATM - fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { - if (err) { - return callback(error(404, 'Patch: Original file read error:' + err)) - } - - debug('PATCH -- File read OK ' + dataIn.length) - debug('PATCH -- parsing target file ...') - - try { - $rdf.parse(dataIn, targetKB, targetURI, targetContentType) - } catch (e) { - debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) - return callback(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) - } - debug('PATCH -- Target parsed OK ') - - var bindingsArray = [] - - var onBindings = function (bindings) { - var b = {} - var v - var x - for (v in bindings) { - if (bindings.hasOwnProperty(v)) { - x = bindings[v] - b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value } - if (x.lang) { - b[v]['xml:lang'] = x.lang - } - if (x.dt) { - b[v].dt = x.dt.uri // @@@ Correct? @@ check - } + var bindingsArray = [] + + var onBindings = function (bindings) { + var b = {} + var v + var x + for (v in bindings) { + if (bindings.hasOwnProperty(v)) { + x = bindings[v] + b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value } + if (x.lang) { + b[v]['xml:lang'] = x.lang + } + if (x.dt) { + b[v].dt = x.dt.uri // @@@ Correct? @@ check } } - debug('PATCH -- bindings: ' + JSON.stringify(b)) - bindingsArray.push(b) } - - var onDone = function () { - debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) - const result = { - 'head': { - 'vars': query.vars.map(function (v) { - return v.toNT() - }) - }, - 'results': { - 'bindings': bindingsArray - } + debug('PATCH -- bindings: ' + JSON.stringify(b)) + bindingsArray.push(b) + } + + var onDone = function () { + debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) + const result = { + 'head': { + 'vars': query.vars.map(function (v) { + return v.toNT() + }) + }, + 'results': { + 'bindings': bindingsArray } - callback(null, JSON.stringify(result)) } + callback(null, JSON.stringify(result)) + } - var fetcher = new $rdf.Fetcher(targetKB, 10000, true) - targetKB.query(query, onBindings, fetcher, onDone) - }) + var fetcher = new $rdf.Fetcher(targetKB, 10000, true) + targetKB.query(query, onBindings, fetcher, onDone) } diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index d98b857ae..de7714014 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -5,14 +5,12 @@ var fs = require('fs') var $rdf = require('rdflib') var debug = require('../../debug').handlers var error = require('../../http-error') -const waterfall = require('run-waterfall') const DEFAULT_CONTENT_TYPE = 'text/turtle' -function patch (filename, targetURI, text, callback) { +function patch (targetKB, filename, targetURI, text, callback) { var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place var patchKB = $rdf.graph() - var targetKB = $rdf.graph() var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE debug('PATCH -- parsing patch ...') @@ -25,56 +23,23 @@ function patch (filename, targetURI, text, callback) { } debug('PATCH -- reading target file ...') - waterfall([ - (cb) => { - fs.stat(filename, (err) => { - if (!err) return cb() - - fs.writeFile(filename, '', (err) => { - if (err) { - return cb(error(err, 'Error creating the patch target')) - } - cb() - }) - }) - }, - (cb) => { - fs.readFile(filename, {encoding: 'utf8'}, function (err, dataIn) { - if (err) { - return cb(error(500, 'Error reading the patch target')) - } - - debug('PATCH -- target read OK ' + dataIn.length + ' bytes. Parsing...') - - try { - $rdf.parse(dataIn, targetKB, targetURI, targetContentType) - } catch (e) { - debug('Patch: Target ' + targetContentType + ' file syntax error:' + e) - return cb(error(500, 'Patch: Target ' + targetContentType + ' file syntax error:' + e)) - } - - var target = patchKB.sym(targetURI) - debug('PATCH -- Target parsed OK, patching... ') - - targetKB.applyPatch(patchObject, target, function (err) { - if (err) { - var message = err.message || err // returns string at the moment - debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return cb(error(409, 'Error when applying the patch')) - } - debug('PATCH -- Patched. Writeback URI base ' + targetURI) - var data = $rdf.serialize(target, targetKB, targetURI, targetContentType) - // debug('Writeback data: ' + data) - - fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) { - if (err) { - return cb(error(500, 'Failed to write file back after patch: ' + err)) - } - debug('PATCH -- applied OK (sync)') - return cb(null, 'Patch applied OK\n') - }) - }) - }) + var target = patchKB.sym(targetURI) + targetKB.applyPatch(patchObject, target, function (err) { + if (err) { + var message = err.message || err // returns string at the moment + debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') + return callback(error(409, 'Error when applying the patch')) } - ], callback) + debug('PATCH -- Patched. Writeback URI base ' + targetURI) + var data = $rdf.serialize(target, targetKB, targetURI, targetContentType) + // debug('Writeback data: ' + data) + + fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) { + if (err) { + return callback(error(500, 'Failed to write file back after patch: ' + err)) + } + debug('PATCH -- applied OK (sync)') + return callback(null, 'Patch applied OK\n') + }) + }) } From 3d05378f334ad6dbf9300518096b3d7ecc1d6d14 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 12:45:19 -0400 Subject: [PATCH 062/178] Remove incomplete SPARQL PATCH handler. The PATCH handler for application/sparql does not perform any patching, i.e., nothing is ever written to disk. Since our next goal is to further abstract patching code (which includes writing), this incomplete code cannot be used. --- lib/handlers/patch.js | 1 - lib/handlers/patch/sparql-patcher.js | 51 ---------------------------- 2 files changed, 52 deletions(-) delete mode 100644 lib/handlers/patch/sparql-patcher.js diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index de644e8dd..1a7a2996c 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -10,7 +10,6 @@ var $rdf = require('rdflib') const DEFAULT_CONTENT_TYPE = 'text/turtle' const PATCHERS = { - 'application/sparql': require('./patch/sparql-patcher.js'), 'application/sparql-update': require('./patch/sparql-update-patcher.js') } diff --git a/lib/handlers/patch/sparql-patcher.js b/lib/handlers/patch/sparql-patcher.js deleted file mode 100644 index 171309535..000000000 --- a/lib/handlers/patch/sparql-patcher.js +++ /dev/null @@ -1,51 +0,0 @@ -module.exports = patch - -var $rdf = require('rdflib') -var debug = require('../../debug').handlers - -function patch (targetKB, filename, targetURI, text, callback) { - debug('PATCH -- parsing query ...') - var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - var patchKB = $rdf.graph() - var query = $rdf.SPARQLToQuery(text, false, patchKB, patchURI) // last param not used ATM - - var bindingsArray = [] - - var onBindings = function (bindings) { - var b = {} - var v - var x - for (v in bindings) { - if (bindings.hasOwnProperty(v)) { - x = bindings[v] - b[v] = x.uri ? {'type': 'uri', 'value': x.uri} : { 'type': 'literal', 'value': x.value } - if (x.lang) { - b[v]['xml:lang'] = x.lang - } - if (x.dt) { - b[v].dt = x.dt.uri // @@@ Correct? @@ check - } - } - } - debug('PATCH -- bindings: ' + JSON.stringify(b)) - bindingsArray.push(b) - } - - var onDone = function () { - debug('PATCH -- Query done, no. bindings: ' + bindingsArray.length) - const result = { - 'head': { - 'vars': query.vars.map(function (v) { - return v.toNT() - }) - }, - 'results': { - 'bindings': bindingsArray - } - } - callback(null, JSON.stringify(result)) - } - - var fetcher = new $rdf.Fetcher(targetKB, 10000, true) - targetKB.query(query, onBindings, fetcher, onDone) -} From 90c218ad61ae279baa18636438ebe7a3a8c217e6 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 13:58:40 -0400 Subject: [PATCH 063/178] Move patch writing to generic PATCH handler. This reduces the task of individual patch handlers to parsing the patch and applying it. --- lib/handlers/patch.js | 51 +++++++++++++-------- lib/handlers/patch/sparql-update-patcher.js | 49 ++++++++------------ 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 1a7a2996c..773743abe 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -32,8 +32,8 @@ function patchHandler (req, res, next) { res.header('MS-Author-Via', 'SPARQL') var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' - var filename = utils.uriToFilename(req.path, root) - var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE + var targetFile = utils.uriToFilename(req.path, root) + var targetContentType = mime.lookup(targetFile) || DEFAULT_CONTENT_TYPE var patchContentType = req.get('content-type') ? req.get('content-type').split(';')[0].trim() // Ignore parameters : '' @@ -42,29 +42,25 @@ function patchHandler (req, res, next) { debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>') // Obtain a patcher for the given patch type - const patch = PATCHERS[patchContentType] - if (!patch) { + const patchGraph = PATCHERS[patchContentType] + if (!patchGraph) { return next(error(415, 'Unknown patch content type: ' + patchContentType)) } - // Read the RDF graph to be patched - readGraph(filename, targetURI).then((targetKB) => { - // Patch the target graph - patch(targetKB, filename, targetURI, req.text, function (err, result) { - if (err) { - throw err - } - res.send(result) - next() - }) - }) - .catch(next) + // Read the RDF graph to be patched from the file + readGraph(targetFile, targetURI, targetContentType) + // Patch the graph and write it back to the file + .then(targetKB => patchGraph(targetKB, targetFile, targetURI, req.text)) + .then(targetKB => writeGraph(targetKB, targetFile, targetURI, targetContentType)) + // Send the result to the client + .then(result => { res.send(result) }) + .then(next, next) } // Reads the RDF graph in the given file with the corresponding URI -function readGraph (resourceFile, resourceURI) { +function readGraph (resourceFile, resourceURI, contentType) { // Read the file - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => fs.readFile(resourceFile, {encoding: 'utf8'}, function (err, fileContents) { if (err) { // If the file does not exist, assume empty contents @@ -79,11 +75,10 @@ function readGraph (resourceFile, resourceURI) { debug('PATCH -- Read target file (%d bytes)', fileContents.length) resolve(fileContents) }) - }) + ) // Parse the file .then((fileContents) => { const graph = $rdf.graph() - const contentType = mime.lookup(resourceFile) || DEFAULT_CONTENT_TYPE debug('PATCH -- Reading %s with content type %s', resourceURI, contentType) try { $rdf.parse(fileContents, graph, resourceURI, contentType) @@ -94,3 +89,19 @@ function readGraph (resourceFile, resourceURI) { return graph }) } + +// Writes the RDF graph to the given file +function writeGraph (graph, resourceFile, resourceURI, contentType) { + return new Promise((resolve, reject) => { + const resource = graph.sym(resourceURI) + const serialized = $rdf.serialize(resource, graph, resourceURI, contentType) + + fs.writeFile(resourceFile, serialized, {encoding: 'utf8'}, function (err) { + if (err) { + return reject(error(500, 'Failed to write file back after patch: ' + err)) + } + debug('PATCH -- applied OK (sync)') + resolve('Patch applied OK\n') + }) + }) +} diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index de7714014..566f89531 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -1,45 +1,32 @@ module.exports = patch -var mime = require('mime-types') -var fs = require('fs') var $rdf = require('rdflib') var debug = require('../../debug').handlers var error = require('../../http-error') -const DEFAULT_CONTENT_TYPE = 'text/turtle' +function patch (targetKB, filename, targetURI, text) { + return new Promise((resolve, reject) => { + var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place + var patchKB = $rdf.graph() -function patch (targetKB, filename, targetURI, text, callback) { - var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - var patchKB = $rdf.graph() - var targetContentType = mime.lookup(filename) || DEFAULT_CONTENT_TYPE - - debug('PATCH -- parsing patch ...') - var patchObject - try { - // Must parse relative to document's base address but patch doc should get diff URI - patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI) - } catch (e) { - return callback(error(400, 'Patch format syntax error:\n' + e + '\n')) - } - debug('PATCH -- reading target file ...') - - var target = patchKB.sym(targetURI) - targetKB.applyPatch(patchObject, target, function (err) { - if (err) { - var message = err.message || err // returns string at the moment - debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return callback(error(409, 'Error when applying the patch')) + debug('PATCH -- parsing patch ...') + var patchObject + try { + // Must parse relative to document's base address but patch doc should get diff URI + patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI) + } catch (e) { + return reject(error(400, 'Patch format syntax error:\n' + e + '\n')) } - debug('PATCH -- Patched. Writeback URI base ' + targetURI) - var data = $rdf.serialize(target, targetKB, targetURI, targetContentType) - // debug('Writeback data: ' + data) + debug('PATCH -- reading target file ...') - fs.writeFile(filename, data, {encoding: 'utf8'}, function (err, data) { + var target = patchKB.sym(targetURI) + targetKB.applyPatch(patchObject, target, function (err) { if (err) { - return callback(error(500, 'Failed to write file back after patch: ' + err)) + var message = err.message || err // returns string at the moment + debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') + return reject(error(409, 'Error when applying the patch')) } - debug('PATCH -- applied OK (sync)') - return callback(null, 'Patch applied OK\n') + resolve(targetKB) }) }) } From fc42bbc8e210c548944134e806a283ea05e19d81 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 14:26:29 -0400 Subject: [PATCH 064/178] Delegate body parsing to middleware. --- lib/handlers/patch.js | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 773743abe..3cea4f38b 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -1,5 +1,6 @@ module.exports = handler +var bodyParser = require('body-parser') var mime = require('mime-types') var fs = require('fs') var debug = require('../debug').handlers @@ -13,24 +14,19 @@ const PATCHERS = { 'application/sparql-update': require('./patch/sparql-update-patcher.js') } -function handler (req, res, next) { - req.setEncoding('utf8') - req.text = '' - req.on('data', function (chunk) { - req.text += chunk - }) +const readEntity = bodyParser.text({ type: '*/*' }) - req.on('end', function () { - patchHandler(req, res, next) - }) +function handler (req, res, next) { + readEntity(req, res, () => patchHandler(req, res, next)) } function patchHandler (req, res, next) { - var ldp = req.app.locals.ldp + const patchText = req.body ? req.body.toString() : '' debug('PATCH -- ' + req.originalUrl) - debug('PATCH -- text length: ' + (req.text ? req.text.length : 'undefined2')) + debug('PATCH -- Received patch (%d bytes)', patchText.length) res.header('MS-Author-Via', 'SPARQL') + var ldp = req.app.locals.ldp var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' var targetFile = utils.uriToFilename(req.path, root) var targetContentType = mime.lookup(targetFile) || DEFAULT_CONTENT_TYPE @@ -50,7 +46,7 @@ function patchHandler (req, res, next) { // Read the RDF graph to be patched from the file readGraph(targetFile, targetURI, targetContentType) // Patch the graph and write it back to the file - .then(targetKB => patchGraph(targetKB, targetFile, targetURI, req.text)) + .then(targetKB => patchGraph(targetKB, targetFile, targetURI, patchText)) .then(targetKB => writeGraph(targetKB, targetFile, targetURI, targetContentType)) // Send the result to the client .then(result => { res.send(result) }) From c92c6ad4cd6a3e7725358a659396003da5e505f9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 15:13:25 -0400 Subject: [PATCH 065/178] Prettify patch code. --- lib/handlers/patch.js | 98 +++++++++++---------- lib/handlers/patch/sparql-update-patcher.js | 22 +++-- 2 files changed, 65 insertions(+), 55 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 3cea4f38b..a8d45ff12 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -1,63 +1,69 @@ +// Express handler for LDP PATCH requests + module.exports = handler -var bodyParser = require('body-parser') -var mime = require('mime-types') -var fs = require('fs') -var debug = require('../debug').handlers -var utils = require('../utils.js') -var error = require('../http-error') -var $rdf = require('rdflib') +const bodyParser = require('body-parser') +const mime = require('mime-types') +const fs = require('fs') +const debug = require('../debug').handlers +const utils = require('../utils.js') +const error = require('../http-error') +const $rdf = require('rdflib') -const DEFAULT_CONTENT_TYPE = 'text/turtle' +const DEFAULT_TARGET_TYPE = 'text/turtle' +// Patch handlers by request body content type const PATCHERS = { 'application/sparql-update': require('./patch/sparql-update-patcher.js') } -const readEntity = bodyParser.text({ type: '*/*' }) - -function handler (req, res, next) { - readEntity(req, res, () => patchHandler(req, res, next)) -} - +// Handles a PATCH request function patchHandler (req, res, next) { - const patchText = req.body ? req.body.toString() : '' debug('PATCH -- ' + req.originalUrl) - debug('PATCH -- Received patch (%d bytes)', patchText.length) res.header('MS-Author-Via', 'SPARQL') - var ldp = req.app.locals.ldp - var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' - var targetFile = utils.uriToFilename(req.path, root) - var targetContentType = mime.lookup(targetFile) || DEFAULT_CONTENT_TYPE - var patchContentType = req.get('content-type') - ? req.get('content-type').split(';')[0].trim() // Ignore parameters - : '' - var targetURI = utils.uriAbs(req) + req.originalUrl - - debug('PATCH -- Content-type ' + patchContentType + ' patching target ' + targetContentType + ' <' + targetURI + '>') - - // Obtain a patcher for the given patch type - const patchGraph = PATCHERS[patchContentType] + // Obtain details of the patch document + const patch = { + text: req.body ? req.body.toString() : '', + contentType: (req.get('content-type') || '').match(/^[^;\s]*/)[0] + } + const patchGraph = PATCHERS[patch.contentType] if (!patchGraph) { - return next(error(415, 'Unknown patch content type: ' + patchContentType)) + return next(error(415, 'Unknown patch content type: ' + patch.contentType)) + } + debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) + + // Obtain details of the target resource + const ldp = req.app.locals.ldp + const root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' + const target = { + file: utils.uriToFilename(req.path, root), + uri: utils.uriAbs(req) + req.originalUrl } + target.contentType = mime.lookup(target.file) || DEFAULT_TARGET_TYPE + debug('PATCH -- Target <%s> (%s)', target.uri, target.contentType) // Read the RDF graph to be patched from the file - readGraph(targetFile, targetURI, targetContentType) + readGraph(target) // Patch the graph and write it back to the file - .then(targetKB => patchGraph(targetKB, targetFile, targetURI, patchText)) - .then(targetKB => writeGraph(targetKB, targetFile, targetURI, targetContentType)) + .then(graph => patchGraph(graph, target.uri, patch.text)) + .then(graph => writeGraph(graph, target)) // Send the result to the client .then(result => { res.send(result) }) .then(next, next) } -// Reads the RDF graph in the given file with the corresponding URI -function readGraph (resourceFile, resourceURI, contentType) { - // Read the file +// Reads the request body and calls the actual patch handler +function handler (req, res, next) { + readEntity(req, res, () => patchHandler(req, res, next)) +} +const readEntity = bodyParser.text({ type: () => true }) + +// Reads the RDF graph in the given resource +function readGraph (resource) { + // Read the resource's file return new Promise((resolve, reject) => - fs.readFile(resourceFile, {encoding: 'utf8'}, function (err, fileContents) { + fs.readFile(resource.file, {encoding: 'utf8'}, function (err, fileContents) { if (err) { // If the file does not exist, assume empty contents // (it will be created after a successful patch) @@ -72,27 +78,27 @@ function readGraph (resourceFile, resourceURI, contentType) { resolve(fileContents) }) ) - // Parse the file + // Parse the resource's file contents .then((fileContents) => { const graph = $rdf.graph() - debug('PATCH -- Reading %s with content type %s', resourceURI, contentType) + debug('PATCH -- Reading %s with content type %s', resource.uri, resource.contentType) try { - $rdf.parse(fileContents, graph, resourceURI, contentType) + $rdf.parse(fileContents, graph, resource.uri, resource.contentType) } catch (err) { - throw error(500, 'Patch: Target ' + contentType + ' file syntax error:' + err) + throw error(500, 'Patch: Target ' + resource.contentType + ' file syntax error:' + err) } debug('PATCH -- Parsed target file') return graph }) } -// Writes the RDF graph to the given file -function writeGraph (graph, resourceFile, resourceURI, contentType) { +// Writes the RDF graph to the given resource +function writeGraph (graph, resource) { return new Promise((resolve, reject) => { - const resource = graph.sym(resourceURI) - const serialized = $rdf.serialize(resource, graph, resourceURI, contentType) + const resourceSym = graph.sym(resource.uri) + const serialized = $rdf.serialize(resourceSym, graph, resource.uri, resource.contentType) - fs.writeFile(resourceFile, serialized, {encoding: 'utf8'}, function (err) { + fs.writeFile(resource.file, serialized, {encoding: 'utf8'}, function (err) { if (err) { return reject(error(500, 'Failed to write file back after patch: ' + err)) } diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index 566f89531..b10f227b6 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -1,24 +1,28 @@ +// Performs an application/sparql-update patch on a graph + module.exports = patch -var $rdf = require('rdflib') -var debug = require('../../debug').handlers -var error = require('../../http-error') +const $rdf = require('rdflib') +const debug = require('../../debug').handlers +const error = require('../../http-error') -function patch (targetKB, filename, targetURI, text) { +// Patches the given graph +function patch (targetKB, targetURI, patchText) { return new Promise((resolve, reject) => { - var patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - var patchKB = $rdf.graph() - - debug('PATCH -- parsing patch ...') + // Parse the patch document + debug('PATCH -- Parsing patch...') + const patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place + const patchKB = $rdf.graph() var patchObject try { // Must parse relative to document's base address but patch doc should get diff URI - patchObject = $rdf.sparqlUpdateParser(text, patchKB, patchURI) + patchObject = $rdf.sparqlUpdateParser(patchText, patchKB, patchURI) } catch (e) { return reject(error(400, 'Patch format syntax error:\n' + e + '\n')) } debug('PATCH -- reading target file ...') + // Apply the patch to the target graph var target = patchKB.sym(targetURI) targetKB.applyPatch(patchObject, target, function (err) { if (err) { From a3f8a7715347ee6c94940e2ac4f3d3fc6ee21cec Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 23 Jun 2017 17:16:23 -0400 Subject: [PATCH 066/178] Refactor SPARQL update patcher with promises. This improves reuse for future parsers. --- lib/handlers/patch/sparql-update-patcher.js | 40 +++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index b10f227b6..8e7633964 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -8,29 +8,39 @@ const error = require('../../http-error') // Patches the given graph function patch (targetKB, targetURI, patchText) { + const patchKB = $rdf.graph() + const target = patchKB.sym(targetURI) + + // Must parse relative to document's base address but patch doc should get diff URI + // @@@ beware the triples from the patch ending up in the same place + const patchURI = targetURI + + return parsePatchDocument(patchURI, patchText, patchKB) + .then(patchObject => applyPatch(patchObject, target, targetKB)) +} + +// Parses the given SPARQL UPDATE document +function parsePatchDocument (patchURI, patchText, patchKB) { + debug('PATCH -- Parsing patch...') return new Promise((resolve, reject) => { - // Parse the patch document - debug('PATCH -- Parsing patch...') - const patchURI = targetURI // @@@ beware the triples from the patch ending up in the same place - const patchKB = $rdf.graph() - var patchObject try { - // Must parse relative to document's base address but patch doc should get diff URI - patchObject = $rdf.sparqlUpdateParser(patchText, patchKB, patchURI) - } catch (e) { - return reject(error(400, 'Patch format syntax error:\n' + e + '\n')) + resolve($rdf.sparqlUpdateParser(patchText, patchKB, patchURI)) + } catch (err) { + reject(error(400, 'Patch format syntax error:\n' + err + '\n')) } - debug('PATCH -- reading target file ...') + }) +} - // Apply the patch to the target graph - var target = patchKB.sym(targetURI) - targetKB.applyPatch(patchObject, target, function (err) { +// Applies the patch to the target graph +function applyPatch (patchObject, target, targetKB) { + return new Promise((resolve, reject) => + targetKB.applyPatch(patchObject, target, (err) => { if (err) { - var message = err.message || err // returns string at the moment + const message = err.message || err // returns string at the moment debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') return reject(error(409, 'Error when applying the patch')) } resolve(targetKB) }) - }) + ) } From 71c31016e1b45dba0dd6241df59917475db6d047 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Mon, 26 Jun 2017 14:29:50 -0400 Subject: [PATCH 067/178] Add preliminary N3 patch support. --- lib/handlers/patch.js | 3 +- lib/handlers/patch/n3-patcher.js | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 lib/handlers/patch/n3-patcher.js diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index a8d45ff12..6d9856e37 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -14,7 +14,8 @@ const DEFAULT_TARGET_TYPE = 'text/turtle' // Patch handlers by request body content type const PATCHERS = { - 'application/sparql-update': require('./patch/sparql-update-patcher.js') + 'application/sparql-update': require('./patch/sparql-update-patcher.js'), + 'text/n3': require('./patch/n3-patcher.js') } // Handles a PATCH request diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patcher.js new file mode 100644 index 000000000..a164633d5 --- /dev/null +++ b/lib/handlers/patch/n3-patcher.js @@ -0,0 +1,72 @@ +// Performs a text/n3 patch on a graph + +module.exports = patch + +const $rdf = require('rdflib') +const debug = require('../../debug').handlers +const error = require('../../http-error') + +const PATCH_NS = 'http://example.org/patch#' +const PREFIXES = `PREFIX p: <${PATCH_NS}>\n` + +// Patches the given graph +function patch (targetKB, targetURI, patchText) { + const patchKB = $rdf.graph() + const target = patchKB.sym(targetURI) + + // Must parse relative to document's base address but patch doc should get diff URI + // @@@ beware the triples from the patch ending up in the same place + const patchURI = targetURI + '#patch' + + return parsePatchDocument(targetURI, patchURI, patchText, patchKB) + .then(patchObject => applyPatch(patchObject, target, targetKB)) +} + +// Parses the given N3 patch document +function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { + debug('PATCH -- Parsing patch...') + + // Parse the N3 document into triples + return new Promise((resolve, reject) => { + const patchGraph = $rdf.graph() + $rdf.parse(patchText, patchGraph, patchURI, 'text/n3') + resolve(patchGraph) + }) + // Query the N3 document for insertions and deletions + .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES} + SELECT ?insert ?delete WHERE { + ?patch p:patches <${targetURI}>. + OPTIONAL { ?patch p:insert ?insert. } + OPTIONAL { ?patch p:delete ?delete. } + }`) + ) + // Return the insertions and deletions as an rdflib patch document + .then(result => { + return { + insert: result['?insert'], + delete: result['?delete'] + } + }) +} + +// Applies the patch to the target graph +function applyPatch (patchObject, target, targetKB) { + return new Promise((resolve, reject) => + targetKB.applyPatch(patchObject, target, (err) => { + if (err) { + const message = err.message || err // returns string at the moment + debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') + return reject(error(409, 'Error when applying the patch')) + } + resolve(targetKB) + }) + ) +} + +// Queries the store with the given SPARQL query and returns the first result +function queryForFirstResult (store, sparql) { + return new Promise((resolve, reject) => { + const query = $rdf.SPARQLToQuery(sparql, false, store) + store.query(query, resolve) + }) +} From aed5b6ac80c1438357683fc641a068f7063d0e50 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Mon, 26 Jun 2017 21:05:40 -0400 Subject: [PATCH 068/178] Construct the patch URI through a hash of its contents. --- lib/handlers/patch.js | 39 ++++++++++++--------- lib/handlers/patch/n3-patcher.js | 6 +--- lib/handlers/patch/sparql-update-patcher.js | 9 ++--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 6d9856e37..128dd3afe 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -9,6 +9,7 @@ const debug = require('../debug').handlers const utils = require('../utils.js') const error = require('../http-error') const $rdf = require('rdflib') +const crypto = require('crypto') const DEFAULT_TARGET_TYPE = 'text/turtle' @@ -23,31 +24,32 @@ function patchHandler (req, res, next) { debug('PATCH -- ' + req.originalUrl) res.header('MS-Author-Via', 'SPARQL') - // Obtain details of the patch document - const patch = { - text: req.body ? req.body.toString() : '', - contentType: (req.get('content-type') || '').match(/^[^;\s]*/)[0] - } - const patchGraph = PATCHERS[patch.contentType] - if (!patchGraph) { - return next(error(415, 'Unknown patch content type: ' + patch.contentType)) - } - debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) - // Obtain details of the target resource const ldp = req.app.locals.ldp const root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' - const target = { - file: utils.uriToFilename(req.path, root), - uri: utils.uriAbs(req) + req.originalUrl - } + const target = {} + target.file = utils.uriToFilename(req.path, root) + target.uri = utils.uriAbs(req) + req.originalUrl target.contentType = mime.lookup(target.file) || DEFAULT_TARGET_TYPE debug('PATCH -- Target <%s> (%s)', target.uri, target.contentType) + // Obtain details of the patch document + const patch = {} + patch.text = req.body ? req.body.toString() : '' + patch.uri = `${target.uri}#patch-${hash(patch.text)}` + patch.contentType = (req.get('content-type') || '').match(/^[^;\s]*/)[0] + debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) + + // Find the appropriate patcher for the given content type + const patchGraph = PATCHERS[patch.contentType] + if (!patchGraph) { + return next(error(415, 'Unknown patch content type: ' + patch.contentType)) + } + // Read the RDF graph to be patched from the file readGraph(target) // Patch the graph and write it back to the file - .then(graph => patchGraph(graph, target.uri, patch.text)) + .then(graph => patchGraph(graph, target.uri, patch.uri, patch.text)) .then(graph => writeGraph(graph, target)) // Send the result to the client .then(result => { res.send(result) }) @@ -108,3 +110,8 @@ function writeGraph (graph, resource) { }) }) } + +// Creates a hash of the given text +function hash (text) { + return crypto.createHash('md5').update(text).digest('hex') +} diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patcher.js index a164633d5..26e3d066c 100644 --- a/lib/handlers/patch/n3-patcher.js +++ b/lib/handlers/patch/n3-patcher.js @@ -10,14 +10,10 @@ const PATCH_NS = 'http://example.org/patch#' const PREFIXES = `PREFIX p: <${PATCH_NS}>\n` // Patches the given graph -function patch (targetKB, targetURI, patchText) { +function patch (targetKB, targetURI, patchURI, patchText) { const patchKB = $rdf.graph() const target = patchKB.sym(targetURI) - // Must parse relative to document's base address but patch doc should get diff URI - // @@@ beware the triples from the patch ending up in the same place - const patchURI = targetURI + '#patch' - return parsePatchDocument(targetURI, patchURI, patchText, patchKB) .then(patchObject => applyPatch(patchObject, target, targetKB)) } diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js index 8e7633964..2544fe2f1 100644 --- a/lib/handlers/patch/sparql-update-patcher.js +++ b/lib/handlers/patch/sparql-update-patcher.js @@ -7,14 +7,10 @@ const debug = require('../../debug').handlers const error = require('../../http-error') // Patches the given graph -function patch (targetKB, targetURI, patchText) { +function patch (targetKB, targetURI, patchURI, patchText) { const patchKB = $rdf.graph() const target = patchKB.sym(targetURI) - // Must parse relative to document's base address but patch doc should get diff URI - // @@@ beware the triples from the patch ending up in the same place - const patchURI = targetURI - return parsePatchDocument(patchURI, patchText, patchKB) .then(patchObject => applyPatch(patchObject, target, targetKB)) } @@ -23,8 +19,9 @@ function patch (targetKB, targetURI, patchText) { function parsePatchDocument (patchURI, patchText, patchKB) { debug('PATCH -- Parsing patch...') return new Promise((resolve, reject) => { + const baseURI = patchURI.replace(/#.*/, '') try { - resolve($rdf.sparqlUpdateParser(patchText, patchKB, patchURI)) + resolve($rdf.sparqlUpdateParser(patchText, patchKB, baseURI)) } catch (err) { reject(error(400, 'Patch format syntax error:\n' + err + '\n')) } From c44c9a139ff8c4bc8c67e0ac6ef06875cee44ef1 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Tue, 27 Jun 2017 13:47:40 -0400 Subject: [PATCH 069/178] Clean up SPARQL UPDATE PATCH tests. --- .../{patch-2.js => patch-sparql-update.js} | 83 ++++----- test/integration/patch.js | 162 ------------------ 2 files changed, 33 insertions(+), 212 deletions(-) rename test/integration/{patch-2.js => patch-sparql-update.js} (68%) delete mode 100644 test/integration/patch.js diff --git a/test/integration/patch-2.js b/test/integration/patch-sparql-update.js similarity index 68% rename from test/integration/patch-2.js rename to test/integration/patch-sparql-update.js index acfeb71cf..d0016d382 100644 --- a/test/integration/patch-2.js +++ b/test/integration/patch-sparql-update.js @@ -1,3 +1,5 @@ +// Integration tests for PATCH with application/sparql-update + var ldnode = require('../../index') var supertest = require('supertest') var assert = require('chai').assert @@ -6,10 +8,9 @@ var path = require('path') // Helper functions for the FS var rm = require('../test-utils').rm var write = require('../test-utils').write -// var cp = require('./test-utils').cp var read = require('../test-utils').read -describe('PATCH', function () { +describe('PATCH through application/sparql-update', function () { // Starting LDP var ldp = ldnode({ root: path.join(__dirname, '../resources/sampleContainer'), @@ -18,52 +19,41 @@ describe('PATCH', function () { }) var server = supertest(ldp) - it.skip('..................', function (done) { + it('should create a new file if file does not exist', function (done) { rm('sampleContainer/notExisting.ttl') server.patch('/notExisting.ttl') .set('content-type', 'application/sparql-update') .send('INSERT DATA { :test :hello 456 .}') .expect(200) .end(function (err, res, body) { - if (err) { - done(err) - } - console.log('@@@@ ' + read('sampleContainer/notExisting.ttl')) assert.equal( - read('sampleContainer/notExisting.ttl'), '' - ) + read('sampleContainer/notExisting.ttl'), + '@prefix : .\n\n:test :hello 456 .\n\n') rm('sampleContainer/notExisting.ttl') done(err) }) }) describe('DELETE', function () { - it('reproduce index 1 bug from pad', function (done) { - var expected = `@prefix : . -@prefix dc: . -@prefix c: . -@prefix n: . -@prefix p: . -@prefix ic: . -@prefix XML: . -@prefix flow: . -@prefix ui: . -@prefix ind: . -@prefix mee: . - -:id1477502276660 dc:author c:i; n:content ""; p:next :this. - -:id1477522707481 - ic:dtstart "2016-10-26T22:58:27Z"^^XML:dateTime; - flow:participant c:i; - ui:backgroundColor "#c1d0c8". -:this - a p:Notepad; - dc:author c:i; - dc:created "2016-10-25T15:44:42Z"^^XML:dateTime; - dc:title "Shared Notes"; - p:next :id1477502276660. -ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` + it('should be an empty resource if last triple is deleted', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/existingTriple.ttl') + server.post('/existingTriple.ttl') + .set('content-type', 'application/sparql-update') + .send('DELETE { :current :temp 123 .}') + .expect(200) + .end(function (err, res, body) { + assert.equal( + read('sampleContainer/existingTriple.ttl'), + '@prefix : .\n\n') + rm('sampleContainer/existingTriple.ttl') + done(err) + }) + }) + + it('should delete a single triple from a pad document', function (done) { + var expected = '@prefix : .\n@prefix dc: .\n@prefix c: .\n@prefix n: .\n@prefix p: .\n@prefix ic: .\n@prefix XML: .\n@prefix flow: .\n@prefix ui: .\n@prefix ind: .\n@prefix mee: .\n\n:id1477502276660 dc:author c:i; n:content ""; p:next :this.\n\n:id1477522707481\n ic:dtstart "2016-10-26T22:58:27Z"^^XML:dateTime;\n flow:participant c:i;\n ui:backgroundColor "#c1d0c8".\n:this\n a p:Notepad;\n dc:author c:i;\n dc:created "2016-10-25T15:44:42Z"^^XML:dateTime;\n dc:title "Shared Notes";\n p:next :id1477502276660.\nind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n' write(`\n\ @@ -114,7 +104,9 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` }) describe('DELETE and INSERT', function () { - it('should be update a resource using SPARQL-query using `prefix`', function (done) { + after(() => rm('sampleContainer/prefixSparql.ttl')) + + it('should update a resource using SPARQL-query using `prefix`', function (done) { write( '@prefix schema: .\n' + '@prefix profile: .\n' + @@ -134,11 +126,7 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` .end(function (err, res, body) { assert.equal( read('sampleContainer/prefixSparql.ttl'), - '@prefix : .\n' + - '@prefix schema: .\n' + - '@prefix pro: .\n\n' + - ': a schema:Person; pro:first_name "Timothy".\n\n') - rm('sampleContainer/prefixSparql.ttl') + '@prefix : .\n@prefix schema: .\n@prefix pro: .\n\n: a schema:Person; pro:first_name "Timothy".\n\n') done(err) }) }) @@ -156,9 +144,7 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` .end(function (err, res, body) { assert.equal( read('sampleContainer/addingTriple.ttl'), - '@prefix : .\n\n' + - ':current :temp 123 .\n\n' + - ':test :hello 456 .\n\n') + '@prefix : .\n\n:current :temp 123 .\n\n:test :hello 456 .\n\n') rm('sampleContainer/addingTriple.ttl') done(err) }) @@ -175,8 +161,7 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` .end(function (err, res, body) { assert.equal( read('sampleContainer/addingTripleValue.ttl'), - '@prefix : .\n\n' + - ':current :temp 123, 456 .\n\n') + '@prefix : .\n\n:current :temp 123, 456 .\n\n') rm('sampleContainer/addingTripleValue.ttl') done(err) }) @@ -193,8 +178,7 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` .end(function (err, res, body) { assert.equal( read('sampleContainer/addingTripleSubj.ttl'), - '@prefix : .\n\n' + - ':current :temp 123; :temp2 456 .\n\n') + '@prefix : .\n\n:current :temp 123; :temp2 456 .\n\n') rm('sampleContainer/addingTripleSubj.ttl') done(err) }) @@ -212,8 +196,7 @@ ind:this flow:participation :id1477522707481; mee:sharedNotes :this.\n\n` .end(function (err, res, body) { assert.equal( read('sampleContainer/emptyExample.ttl'), - '@prefix : .\n\n' + - ':current :temp 123 .\n\n') + '@prefix : .\n\n:current :temp 123 .\n\n') rm('sampleContainer/emptyExample.ttl') done(err) }) diff --git a/test/integration/patch.js b/test/integration/patch.js deleted file mode 100644 index c5247c799..000000000 --- a/test/integration/patch.js +++ /dev/null @@ -1,162 +0,0 @@ -var ldnode = require('../../index') -var supertest = require('supertest') -var assert = require('chai').assert -var path = require('path') - -// Helper functions for the FS -var rm = require('../test-utils').rm -var write = require('../test-utils').write -// var cp = require('./test-utils').cp -var read = require('../test-utils').read - -describe('PATCH', function () { - // Starting LDP - var ldp = ldnode({ - root: path.join(__dirname, '../resources/sampleContainer'), - mount: '/test', - webid: false - }) - var server = supertest(ldp) - - it('should create a new file if file does not exist', function (done) { - rm('sampleContainer/notExisting.ttl') - - server.patch('/notExisting.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :test :hello 456 .}') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/notExisting.ttl'), - '@prefix : .\n\n' + - ':test :hello 456 .\n\n') - rm('sampleContainer/notExisting.ttl') - done(err) - }) - }) - - describe('DELETE', function () { - it('should be an empty resource if last triple is deleted', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/existingTriple.ttl') - server.post('/existingTriple.ttl') - .set('content-type', 'application/sparql-update') - .send('DELETE { :current :temp 123 .}') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/existingTriple.ttl'), - '@prefix : .\n\n') - rm('sampleContainer/existingTriple.ttl') - done(err) - }) - }) - }) - - describe('DELETE and INSERT', function () { - it('should be update a resource using SPARQL-query using `prefix`', function (done) { - write( - '@prefix schema: .\n' + - '@prefix profile: .\n' + - '# a schema:Person ;\n' + - '<#> a schema:Person ;\n' + - ' profile:first_name "Tim" .\n', - 'sampleContainer/prefixSparql.ttl') - server.post('/prefixSparql.ttl') - .set('content-type', 'application/sparql-update') - .send('@prefix rdf: .\n' + - '@prefix schema: .\n' + - '@prefix profile: .\n' + - '@prefix ex: .\n' + - 'DELETE { <#> profile:first_name "Tim" }\n' + - 'INSERT { <#> profile:first_name "Timothy" }') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/prefixSparql.ttl'), - '@prefix : .\n' + - '@prefix schema: .\n' + - '@prefix pro: .\n\n' + - ': a schema:Person; pro:first_name "Timothy".\n\n') - rm('sampleContainer/prefixSparql.ttl') - done(err) - }) - }) - }) - - describe('INSERT', function () { - it('should add a new triple', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTriple.ttl') - server.post('/addingTriple.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :test :hello 456 .}') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/addingTriple.ttl'), - '@prefix : .\n\n' + - ':current :temp 123 .\n\n' + - ':test :hello 456 .\n\n') - rm('sampleContainer/addingTriple.ttl') - done(err) - }) - }) - - it('should add value to existing triple', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTripleValue.ttl') - server.post('/addingTripleValue.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :current :temp 456 .}') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/addingTripleValue.ttl'), - '@prefix : .\n\n' + - ':current :temp 123, 456 .\n\n') - rm('sampleContainer/addingTripleValue.ttl') - done(err) - }) - }) - - it('should add value to same subject', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTripleSubj.ttl') - server.post('/addingTripleSubj.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :current :temp2 456 .}') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/addingTripleSubj.ttl'), - '@prefix : .\n\n' + - ':current :temp 123; :temp2 456 .\n\n') - rm('sampleContainer/addingTripleSubj.ttl') - done(err) - }) - }) - }) - - it('nothing should change with empty patch', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/emptyExample.ttl') - server.post('/emptyExample.ttl') - .set('content-type', 'application/sparql-update') - .send('') - .expect(200) - .end(function (err, res, body) { - assert.equal( - read('sampleContainer/emptyExample.ttl'), - '@prefix : .\n\n' + - ':current :temp 123 .\n\n') - rm('sampleContainer/emptyExample.ttl') - done(err) - }) - }) -}) From 4d1e746eb4f211387c855fe1c0d774f73b8dacbf Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Tue, 27 Jun 2017 17:10:19 -0400 Subject: [PATCH 070/178] Set up new PATCH tests. --- test/integration/patch.js | 50 +++ ..._key_055bd7af37f092a8ccdca75fec9ee4bc.json | 1 + ...ey_https%3A%2F%2Ftim.localhost%3A7777.json | 1 + test/resources/patch/db/oidc/op/provider.json | 411 ++++++++++++++++++ ...ey_https%3A%2F%2Ftim.localhost%3A7777.json | 1 + test/resources/patch/tim.localhost/index.html | 0 .../patch/tim.localhost/read-only.ttl | 1 + .../patch/tim.localhost/read-only.ttl.acl | 6 + 8 files changed, 471 insertions(+) create mode 100644 test/integration/patch.js create mode 100644 test/resources/accounts-acl/db/oidc/op/clients/_key_055bd7af37f092a8ccdca75fec9ee4bc.json create mode 100644 test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json create mode 100644 test/resources/patch/db/oidc/op/provider.json create mode 100644 test/resources/patch/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json create mode 100644 test/resources/patch/tim.localhost/index.html create mode 100644 test/resources/patch/tim.localhost/read-only.ttl create mode 100644 test/resources/patch/tim.localhost/read-only.ttl.acl diff --git a/test/integration/patch.js b/test/integration/patch.js new file mode 100644 index 000000000..42b689469 --- /dev/null +++ b/test/integration/patch.js @@ -0,0 +1,50 @@ +// Integration tests for PATCH +const assert = require('chai').assert +const ldnode = require('../../index') +const path = require('path') +const supertest = require('supertest') + +// Server settings +const port = 7777 +const serverUri = `https://tim.localhost:${port}` +const root = path.join(__dirname, '../resources/patch') +const serverOptions = { + serverUri, + root, + dbPath: path.join(root, 'db'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + idp: true +} +const userCredentials = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IkFWUzVlZk5pRUVNIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiaHR0cHM6Ly90aW0ubG9jYWxob3N0Ojc3NzcvcHJvZmlsZS9jYXJkI21lIiwiYXVkIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJleHAiOjc3OTkyMjkwMDksImlhdCI6MTQ5MjAyOTAwOSwianRpIjoiZWY3OGQwYjY3ZWRjNzJhMSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.H9lxCbNc47SfIq3hhHnj48BE-YFnvhCfDH9Jc4PptApTEip8sVj0E_u704K_huhNuWBvuv3cDRDGYZM7CuLnzgJG1BI75nXR9PYAJPK9Ketua2KzIrftNoyKNamGqkoCKFafF4z_rsmtXQ5u1_60SgWRcouXMpcHnnDqINF1JpvS21xjE_LbJ6qgPEhu3rRKcv1hpRdW9dRvjtWb9xu84bAjlRuT02lyDBHgj2utxpE_uqCbj48qlee3GoqWpGkSS-vJ6JA0aWYgnyv8fQsxf9rpdFNzKRoQO6XYMy6niEKj8aKgxjaUlpoGGJ5XtVLHH8AGwjYXR8iznYzJvEcB7Q' + +describe('PATCH', () => { + var request + + before(done => { + const server = ldnode.createServer(serverOptions) + server.listen(port, done) + request = supertest(serverUri) + }) + + describe('on a resource to which the user has read-only access', () => { + it('returns a 403', () => + request.patch(`/read-only.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> a p:Patch; + p:insert { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + }) +}) + +function n3Patch (contents) { + return `@prefix p: .\n${contents}` +} diff --git a/test/resources/accounts-acl/db/oidc/op/clients/_key_055bd7af37f092a8ccdca75fec9ee4bc.json b/test/resources/accounts-acl/db/oidc/op/clients/_key_055bd7af37f092a8ccdca75fec9ee4bc.json new file mode 100644 index 000000000..6786c1dae --- /dev/null +++ b/test/resources/accounts-acl/db/oidc/op/clients/_key_055bd7af37f092a8ccdca75fec9ee4bc.json @@ -0,0 +1 @@ +{"client_id":"055bd7af37f092a8ccdca75fec9ee4bc","client_secret":"0975bff497dbf280df1e5e7bd96b42fc","redirect_uris":["https://tim.localhost:7777/api/oidc/rp/https%3A%2F%2Ftim.localhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://tim.localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://tim.localhost:7777/goodbye"],"frontchannel_logout_session_required":false} \ No newline at end of file diff --git a/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json new file mode 100644 index 000000000..b59876fe8 --- /dev/null +++ b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json @@ -0,0 +1 @@ +{"provider":{"url":"https://tim.localhost:7777","configuration":{"issuer":"https://localhost:7777","authorization_endpoint":"https://localhost:7777/authorize","token_endpoint":"https://localhost:7777/token","userinfo_endpoint":"https://localhost:7777/userinfo","jwks_uri":"https://localhost:7777/jwks","registration_endpoint":"https://localhost:7777/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7777/session","end_session_endpoint":"https://localhost:7777/logout"},"jwks":{"keys":[{"kid":"ohtdSKLCdYs","kty":"RSA","alg":"RS256","n":"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"gI5JLLhVFG8","kty":"RSA","alg":"RS384","n":"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1ZHTLTyLbQs","kty":"RSA","alg":"RS512","n":"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"AVS5efNiEEM","kty":"RSA","alg":"RS256","n":"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"ZVFUPkFyy18","kty":"RSA","alg":"RS384","n":"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"mROehy7CZO4","kty":"RSA","alg":"RS512","n":"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"it1Z6EDEV5g","kty":"RSA","alg":"RS256","n":"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"055bd7af37f092a8ccdca75fec9ee4bc","client_secret":"0975bff497dbf280df1e5e7bd96b42fc","redirect_uris":["https://tim.localhost:7777/api/oidc/rp/https%3A%2F%2Ftim.localhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://tim.localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://tim.localhost:7777/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiMDU1YmQ3YWYzN2YwOTJhOGNjZGNhNzVmZWM5ZWU0YmMiLCJhdWQiOiIwNTViZDdhZjM3ZjA5MmE4Y2NkY2E3NWZlYzllZTRiYyJ9.WuCn29IYlBTlDwJlA49MTKV7KQORVD7ZhY3OC3BI1NeC9kMmPfHebzvXq2Ms52jGZbJDWXfqwHYwW4osd-g0FI4s-9H4kJxxrqTJSQ6_pqlFbCst_4Ul7UIBcL-JUrBlYFG_DD30w3IOoVYJmV6nW6MMYzcQ8Mi-d-4Iqi7lJ3-kFGwUSgZ4-9PXVGd1Ps3TdCrN6qN6_y0d6K8qkneK0ERMWy7YnvdA8gxVoc6oVfIqB4FTLIhjQFcAc11hEXv9Z1m-aHfhEmUpDr8zHQRQbsCcDLt-UKl66bg7iv9jBqZfcuswRXNprWtXC_jjLUhLosWbKc-Gmz3jHV6UxwevQQ","registration_client_uri":"https://localhost:7777/register/055bd7af37f092a8ccdca75fec9ee4bc","client_id_issued_at":1498596175,"client_secret_expires_at":0}} \ No newline at end of file diff --git a/test/resources/patch/db/oidc/op/provider.json b/test/resources/patch/db/oidc/op/provider.json new file mode 100644 index 000000000..88504f9ec --- /dev/null +++ b/test/resources/patch/db/oidc/op/provider.json @@ -0,0 +1,411 @@ +{ + "issuer": "https://localhost:7777", + "authorization_endpoint": "https://localhost:7777/authorize", + "token_endpoint": "https://localhost:7777/token", + "userinfo_endpoint": "https://localhost:7777/userinfo", + "jwks_uri": "https://localhost:7777/jwks", + "registration_endpoint": "https://localhost:7777/register", + "response_types_supported": [ + "code", + "code token", + "code id_token", + "id_token", + "id_token token", + "code id_token token", + "none" + ], + "response_modes_supported": [ + "query", + "fragment" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "client_credentials" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "none" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256" + ], + "display_values_supported": [], + "claim_types_supported": [ + "normal" + ], + "claims_supported": "", + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": true, + "require_request_uri_registration": false, + "check_session_iframe": "https://localhost:7777/session", + "end_session_endpoint": "https://localhost:7777/logout", + "keys": { + "descriptor": { + "id_token": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + }, + "RS384": { + "alg": "RS384", + "modulusLength": 2048 + }, + "RS512": { + "alg": "RS512", + "modulusLength": 2048 + } + }, + "encryption": {} + }, + "token": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + }, + "RS384": { + "alg": "RS384", + "modulusLength": 2048 + }, + "RS512": { + "alg": "RS512", + "modulusLength": 2048 + } + }, + "encryption": {} + }, + "userinfo": { + "encryption": {} + }, + "register": { + "signing": { + "RS256": { + "alg": "RS256", + "modulusLength": 2048 + } + } + } + }, + "jwks": { + "keys": [ + { + "kid": "ohtdSKLCdYs", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "gI5JLLhVFG8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "1ZHTLTyLbQs", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "AVS5efNiEEM", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "ZVFUPkFyy18", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "mROehy7CZO4", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "it1Z6EDEV5g", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + ] + }, + "id_token": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "v-cHHQPNDvo", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "d": "pv-ULqejr_iYV8Ipm2yTv3_Lnu0GnZrjB0eW8u1Tr0Z8LSlALWn5b0DOgFXcl6iRebym5M9Hs6qLeSlMS2a-1rM5HVUR_x_RuLwojHbXPXsct-raoymD66xs8iLJw1f3uF5RTpn2fkR1ycHww-bO92hUdx6Y5Rdqfk5ZkMncuRIJI4PHrYcSxaGogl5JNL_Bzza5Sb8-GGV0Ef5wB9S4CM2VUgLj2r5RzwpezcrIA0w9TnbtEdA5EEdHG997jgQhp-fSUPKMtKrRRFJy_JqIYRUi4SOLP_gJYO_qpJlb9pxVQMVnhhXTnso-pSCfsxCTxRjb176BahlG3kuNTiwXKQ", + "p": "5JrtuYCK4-apgRriDLC2_LpVjlnioLoHHUGyYh8SZPwpOzDoQI3EOIZyFM0X9hRMBWoNXjgCUGhdwwAfw24JgKSx_Obni3pRVz69skm-Ee1dCRlDGi91B9q3-cNJG0qJI9mIPIRp2PCCvXToC48PVDkBm3t7zdzRPaosu_YWkrM", + "q": "yI-68nioykS5WrcvjKpsGke7O7MZ22sj9EGtPBRgoxSrDzZK9MutnM_9_vMYPGZy1cN8Ade1-Jw7qA8w8ZESeu5E4cQkArgpdVG34EEDz61A5SYf4GkD-qJ803TxZcmfqfGX-REoKUNafLaNbhQsOHrhrdN2oH-CZq2KrVHCt2U", + "dp": "zMGn49sqi-5yLF0z00IE5GDReOsxfdyhuqa5bAGArErfc1De9dMEycxCKjd5GsQbQ042IwnvqK2SLbLSwGyyvjLF6Uu4YMlySb68khBS2iPMjPW_kJipLhvNZTxxIqykISQaTnobhGAH-kHYBWJhzIIy2lzECyOZlq3x23kTxtk", + "dq": "etoP2ZavTbbrEvZC2hdKQI7P0bHTlOP8EhJo2vRgfYSbg6XuJCTfI78EBrdBkT3v-aDUxQwtGywYHsmvYUlL2KE68FAE_uVv_70etO8eNogZyEOiIwQwu8XsUFrBw2fNtXuXa6lmwF_RfbMUzujsbWxX8PInKAjzB5Il8CS08UE", + "qi": "GBJ90AkXHhbgiL4yk9w6MtQxi1F8XRHBpG3t97Aj1we14pITY56vpEJi97gUjsRsH9DZqzIFV62CSF0VMWaxxRX3c6yuUtJMBSq9Skpvipjwatlz3jxHGP26IFSO9b-NpidM9_egK5mYlGuNY0N1CN-7Lw_Rpt8cvrvvi2tB41c", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "ohtdSKLCdYs", + "kty": "RSA", + "alg": "RS256", + "n": "sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS384": { + "privateJwk": { + "kid": "5Rhg743p3K8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "d": "vvMZLzmQ7APUR0Jz6YiBRdmSZVX-D5ZcRVXJvZbDYeLpuA7W6Nfqk3kKmNLJ-PbV1AQP86OypU4IHJJcLYP_VKpt8Xnq5GItqPZQmBtPRLMSzVF8_UIzS1xORKGkEIWGUy-gyfIWUHnfRnFS8l2tlgLE_5H12YMgg4AuJKY_WkxJSedTKwr4K0COthvbMREqIGbNg9JJhJh54K2FtuNNqn4iycaYCNveunWekRBMpzL2IGsjECGtI4NSrjtneWpIY71pggG87QGduYGVgbdBYFSnJlgbCjN7bQNzpI8v7uE4eM7q6tphJMasVjCS1TGIuNZDl_-vfyCySkNlSvIyAQ", + "p": "6sPjPxcGVwAX1ADLxs7YRN_1U1xYUV_UenzTAnaNac5W8s-AQDoW7_6oCD3s0EmBRWsT_jhGbDUyMgJa0ZASa3nJVqXdYTrrxaBcOktUpLvq2cRgcxLkH_CYdT6yQMeUIjnAg5z-Rkjg0lvWPvqi-IVKDcoFUuF2sjGJjeF9d3k", + "q": "5_m_mSjbVM9ZGvvr-XDAybD3z2JPft1PjCISHcNdTe0-gu4z7VXNnIgynhD0JIee8UpEnBrPFOd7raPxY-y4wdYF-zE3gvl9IOveG793uPctvbWtQSYpcZuPWodn8t-3LvZNq5kLZLCSUIrgTJiwIS7v5Ihc5fxVuyJSYHeBtWE", + "dp": "4yrZ4lqtT9JPPF3o0V-l9j-gbCGXdGZ-fGf85w1AmXmIuTwApiWPvHt2rUL-vC3kYP_UQNLDkkGHaMzOhKocqNMX-DhXl5YkPv-FPwNVzHHqNv7HNZK6HA37-LfKVNTKirPHjZOEmQ48PlGPZzGwMTsJBX7O1_xDlvpIWHoxpkE", + "dq": "KQC8HRZbrmH4HgzpaO3FJeFh7AY0hvgXV22uRhSCKYQFyJ7SDuFbto9cYxQcE1jlf0DhX7ZdZBSGh-qygDcXcSujYwMQDNaMh4UpfT4aq1cFfsLeHOXh7XLRo-7LMOLaPjLLB8nFeca8FgB2JRPYDgV94ac4xG4VuT4X0XVOOAE", + "qi": "gVHniXGKh_ewcrZRRei-ujdYm-htsGYGjmCyXXQ_RVJYz9tauSzmBQPGfE088Wp4ybyTv0exZ_MnizFDHIpP6TWt_Dg5uYWP2UHbKdwdAs8nA9NSXdUFtyE06HsYx-Rd8APYl6A0oCjENweAx7xq9R4zbdMdZpmpX8v2N5WSZN0", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "gI5JLLhVFG8", + "kty": "RSA", + "alg": "RS384", + "n": "1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS512": { + "privateJwk": { + "kid": "WHTKUBTBjl0", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "d": "nRMhd1yDQ3PjLQpLSRnM3hEu5kfJBi41tX77GrchDgs36AocKsYwPqLKjB3FVcGRQpPbQamtv4ArmCzlQdW3uhIZRKhpqZ5Fwr_WG5uWyM_ZWL6b33n3KHVWONzSp1id9jmtoicSlQUANKVSw_CDqmlvbDiKrLpqEyCTkGClG1XCMpTRq0IA_D19ZORd3XvdBePN1H2djX9Lh6ODW39iVdoDkj8b46STakIbu9rHUwA8ZusGd671JnXB4OemX71MCi677_GN1r5buWc8puFV8mrv-kYfk4hPyXQqZAqo9AbgoNbRb62OoWhs5mzmPYoxLyGNeUOedqefmSCQbQl1gQ", + "p": "5yAwbWvBFS3Wtgd4ncPQRkEqPVjaKU3u5VWdytdZylkGNVfB95WJiBJmLa1_arlMmKLuZlHAzgNDmd7_R0F6Bd8_mLynaxk3MTzsrawk17HTXkPX5k9jm8XDc1F6wvK9kL2Xc41DCvalWt6QMQXzJdQNJWy-mJx0He5CrULMHNc", + "q": "zXJhNeBWNg8s9QGufel8Mlewbu_e2cQVsolZZOgXlkj8_IbeRzH0PeHbzbSmabv4tJ36X579ddK5MSpL81sZ5ZbuPFYVVJCb4jzVtDFfNcgkM0OfRj_2F_T1JI2H1WKwHowTyQiXVp8xrECUg0DzkMpH-lse7fkrrS0-Vne92ME", + "dp": "lF6Wl_efWJA3kF0Vcfmc_yygCAe87N0JqhEfHXLHQl2J3b57VwuY4VAmZdZFwGY5pJabgfWjVtzDjciYic6fnZtmAQ_CTb8_Lg2VRhwG_qw6Kv5UX5XBNONsh9_bdcBMLtl2mwgo7KXPGplbaQ0PvM32rnqzk9aDuB8WkJEb5Ls", + "dq": "X7WQaev33blWJVHCO3BBZqaJUDU5KVP7E7B-z8575oxcJzyhYqN3-Dg3EO6-s_VY2LPcBx3nUDN6CNh-h4GCX_3fQIaN61Zu-IeEuyxhAYoaqzMuiSiU-fYpGf1BMXyHNcPmF7qD3lvNZUS0qyzgCyzhOVWn5A83dLbmGpwv-kE", + "qi": "pD0kXsVUjZWnDoExmpB2QnQcSP_op-OPebrLqHVzsXBZfpkf4Do48yrnL0BjI825008dDDq3fHXxWR42Vc27zHDvkaqg9ZJpCQIOpY2jKT1jYZ-HYqQeqvXCDSHM11hfkce0OaBGhcWCKaOX3-wB8sDmD-8K3DpCTuplXCGBeWU", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "1ZHTLTyLbQs", + "kty": "RSA", + "alg": "RS512", + "n": "uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + }, + "encryption": {} + }, + "token": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "wrLgRGiRzLQ", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "d": "iI67yDEBeSXXpvqvQgVtHtTUf5rj2DaRVmiFqZIy6eN5dQdoNVq4LNwua3mIjZR5di1se7Vpwqe_E_6mt94IWnXwTiDDze_Y00glOQnJ9BHr53Enl5x6Rtjf555wFmRJ1-Gt3tgMfnpxWiHhwlQ6AMGjDeht9PB4lOCeXPjPUUvbkKKKBWBtVw-8e9hPZdJFjmMU_bmYL9i-gXMf6xWn4JLkrO-lVDvAqG7jlHdFN49HFBxFuxw-T4DY0GTd8OfnOBSWGaleADncTaUKL6dvXwgNtnes_PPKUfJ6BTgYpmM_4HhWMuuosarxhJAwkGoWu7LRm4W_jy5QUDFIVqTj4Q", + "p": "6MBN0ZdNba70Y3lEijgyYDE2oFtLFs3b9HtmLpr4_vQ-b0o4iasQO5bYmVW54rDvP_rCyBDs7uZUvoqeYD-xRYiPDErS5AzoeVNDoFS29fC2mNVPSqNBFOcRnqSMStuvAQwYR0zkYuCz1paAbLTZuiEmamNKx9Sxt4-FrEq6uqc", + "q": "xyHr9MFcb5VYir3d2_yRs0glIk_LNgT5uqv6R2I49iD-Z-w6EBen7M1ttkqXWA3J_kIufM75MwDjTpOFjO1Q7GVCVV5T4W9vs34Ko3u4jPJziECeIFV1ZDfyHk813eGhaGh9R_oqHe47vE2wBeRPzpIWj3ZG8yOrSTbn7eOEzG8", + "dp": "4zfRAHqPuTMiG_YoBjOEYknJBVT6giGnyA2rnHXn_KWeSfEQLr2UFEhX3aFF3dtTRYddHgj_9N1g_769jELBoZsF4z8skDtVvBOgImZxUrmS2LLtPHURtQE7Pz9uQioit4gCL6EOGMU6a5Pzfaw0HbP9F8ElIN4wPH3dRmyRzGM", + "dq": "iqVFog4XC-HR2hfEJuy9jTQIFtGzzRK9xYkEIztyKXxjZXwGGTo_QxLs9mUM5tQC9bKip2d7_lT57rWr4KlDFLST8NhSUr3B6hkx0w3LOud8JTvIXP7jUznYq92-xZPZS9akk77MIDbFBKCalB-YqVzxtEVHtPX6xmkiJnGo_qU", + "qi": "ZqcNWxzQ7lI4JxsQQhKTFAghR6J7QJMaqiiTrUiaWOSlB33kRKEdv3s1LAfMNdbGr3zl-Buhj5LOX-tPvWSV4ua9GumiHOr90Nm_WiTAJT2gbtXKToaJHSk_BeKN_8feak0Mvzwxphv8xz6C96NbXwDIDTV5YQweRFvQY5Mpmho", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "AVS5efNiEEM", + "kty": "RSA", + "alg": "RS256", + "n": "tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS384": { + "privateJwk": { + "kid": "1IGzLGffBQI", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "d": "qLX9ra72QELyDjg-k-Ql8ILfTFZjsn9X7QxJZLJn-e0ytgM0X-2blYj7nBC4mpTRlolJjtADVIBNCd5ryWJw8iAQwyhXz0mmMhWtQ4qml5mhI9B1RNoOOPFdcgQQEACcZ2bk-MP_HuoLm8Ju6XMsUvv0FfcXB9xtJkicEMdkGEe3uchr384r5t38ffaC-8ZA9enSoHBZwRaxFlt3i1TAGFwwQNeIsssrXJrUXi-YlZqmXaRf2Gl0fboXboFLXaWTN5RfD1iQ1zUBg55XswpkJhyR6D81XZLrTK-jOEbrhrclj5jujtk5TeqYrIZtMBNUwgRGzFkczkcNCWilFqX0aQ", + "p": "1RzqSRl2tZQrvDYVJIkufxtI-GvXVjIZYM2TUkCinAoHKN7QlwwL0QAXamr144v9JCGbMEIjcFo16Rj1Py77jLjE15ybdZpHqz3Gy_htjp0ySHJMI-T5Bxm5JxuPQLYj3k9Bhik-HcsQxJHKPXUZqpDDh-ivySd4UuGBpKOZXSc", + "q": "zc_wXz6sqrSHQPH6Yrr6oVJPmvwzBFv05g4NvWwoATZavuGo2-BdkqZVVaTPwBEB-BBgWz_VBhn48sV0gqN6mZOI9897HraPIwoNX1eWvfqPliMmbj9bHB99ZZtPqLcA6JXt3pISdE8mfEUHm65tUdvZ7l9wlU_RcHXdOS_javs", + "dp": "K9w3m7PR6q0EE0hOMabKGv7Slc4cE3FcJ8AngdYroVGvB4pUA8JG7EzIhO5ejOZSwwznk5cJFCZ80eyBDO_udZfRa06f8CRAe83LDE-kvKU9pAtiAEEvv3Zb1OCnKvpRh39oTORQFHGmkc4vgVaIYcJJe7837n5hFS20MN46wiE", + "dq": "mC1qZGJpNYdqgqDpLFtouiOsbMKRzmVX_Uri6e6w3cSc8IrWWk3ZoneOnVbRrghlVlB1jsLx9iL6KjfJ4FaUbj3ihqlJNfpyd8wU-yw-b5Z22OKApf_-lBrMk3Z1PiCicVd6nJmRP6LOqBA6gehFOMPArjqvehecmvTrcD9yfkU", + "qi": "BAG5sXbnpXWa0kUNCFgsX6YREYvSkrdeCLnpUHSw0ydU9xLswRBiQaYjoTWNHG1IfiSU-ascFqW-xZGlTEi8HDKamxZqYDyxvUMpYvSOleeMEK7Ieq580FQlzNHQ3supNMr6WK0cHsxs0dw3MBFkI4k7QknB5-mOLNvPD-F57Dc", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "ZVFUPkFyy18", + "kty": "RSA", + "alg": "RS384", + "n": "q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + }, + "RS512": { + "privateJwk": { + "kid": "zVgFtyyWWik", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "d": "taDca0jd5D7AfSYS5ea7vqZgvDPhEaBGfBMBxhXE1XRXkwSfbcQ8XbTjlWgvTOZcovxPInELJUFUv8SqEQqi-4YnM_M7LcwEFiUSjXGfOWYelgFYmh80YPMlZ3ZEVlaeDPzwy9DPH3Wc3RKrM0CV9cQiOMcy2hmZneCztEvFbohMI8bXFYeZRA-i7qJH9N3Cj_9iqGlKqnSEBl59IJX6FacX8EVi6FwCXWpJI5b6afab0dHBeZBjN-ZqRtR_kf78gaTSUKySJNrCoXpAun2HvYFXJYrt0byWho9wKt5x35SF3jcJ-DwEzjlCP9kZfw8XVPORh4tXKlbu_IrH0Ia-QQ", + "p": "_jIqpz116Ae6tqpH_HN5eT-ywJOHN_RJWbETsguBEWXjxJFdsP_M_34Rl1_T2Cz97iqde40IgkiCw2naUupwDdzY3DrmH0l8Z5nM6hyteRS14Y3z3GhX9Z_3BdsLSd76gpQdbN2C8QlG8OAHW0xT-6vYwo2sYHhEdBdmnBGIs4s", + "q": "yBdQ6sU6Px5M4sL3KR9cBTxOXJit-9Y8wdHPaSbmAZY-zVXTBWR0geLG_Dkx_c3NncSrSwWUlSVjLg-MG0EWr1W_dEjBWvAFjvCRqNoaZNFkOU_j9LG6zVyR6XPihENglaYZF3pKVDOjSqT2j0OIztcenHxd3sTE0BVBvEoedZU", + "dp": "3Cwdr7_XaYOQYPl64pouhCv9Kzpda8TG584t7hBy2dvz_eWfTlkyebX7jK7u8hZ-V5VH1KUi0p31zUbZWOpA5nD80Tye6EihXabky37NbsvWgiiPKcCjN1g4ATVqQLDHMOUT26C98wMDFE4ncRfawmlllZZa0TA6soc2VEYHruM", + "dq": "VIKEiqQCleYWUzBFc_jqxMtTzYgu887omnQjRiZHvyPWIqO9HOnwy2sc4CrIEop57cjDEEyrFNNVsH6gjmJPUn7E_jg8ckwuDNFOtCJqQ2qtCgfUH-VxIIuYlSF86qAKiyo8Ls5X1nh4324NNTUw8yuooi9k9lHlTn2r5froIoE", + "qi": "1YMuA45Yn4yHMa_B4xbRdnXdKehWJlSiksNfTbNINUqvLwOQDhCqVaPoamde4tS2nzT-ZQTxrp5jQqFGjgjTm0-p2EIFdzjs0NtLMDeEuMiHaxp7Ov1LpjdffTn_WknFgQtkjgygg2e5XQrEWDSzqNeV06blIbnegk1YnE6c8Lg", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "mROehy7CZO4", + "kty": "RSA", + "alg": "RS512", + "n": "xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + }, + "encryption": {} + }, + "userinfo": { + "encryption": {} + }, + "register": { + "signing": { + "RS256": { + "privateJwk": { + "kid": "wySK0UGZma8", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "d": "pIK2-QeajDDD5wWTn8AGqhs5JOgTv4lDQL6t1i_8HqFxZNloba8DWrOeJS9_yOP9maCkdQAoS83TzWFOcf7fOFWEAAYNen86ifyCbIA8T63W0t9l1FnuBsMoI9dVUD5nbQKWVGc9Vflo4W65cTineM3ur2TA7TcTrZALHGpQ3hU9hSLPzPmazeeNKSEwy-euD3Cjm85FLdlNHrk6Leb65zbOs6fumxwUVaBq-KmyK7EerUPeAUh0K4Xy0BFt1L1x9XI4unZDG4HfR177eDS_vvL_N20KzFWZvbWJeuiwGZn2NwIeaA0kIcVHpd3gUrEy9DaV4tsrfhsUZb6apylSgQ", + "p": "_9tQNveciKNBxgX9GepZ3G5VLMQhjkvInIlA-seE2moudpsPnnZqk2ZEc0Zl7XTeoTv1fBczUZx06H4hj0gdAhkHPLUJz0YtasXyRSX53aDICacj4rJYw78a-eSJ3tBKkbDV0Q24MkDY3p3MlVAAycxwLS0wHPc7GPQwPa7K39c", + "q": "xbR0fb0vrARDTZB51sU15L9tzSvPNwkt1O07lZolgoFdDgX_0ADgqv0iHgSlBQR9hoKHTqeEAjbkxRHBmv2KIhH_cLcESMU4JkTs-j1kz5diprfuutWWvs57XjCvewbbp59l3lZFc54WeXjzBWTSxvaXTlwBlCwJHAJiF1Dw83E", + "dp": "SdcfpV183apQNzhPPYV2_bkR9-N607hnY1XxXO7sFqUCV9SUg2UliPjA1IwCqq9J-Tp2tKN1eh4vV1HfmZx0UsCqaAjPlfRo8yHBs9cr75yRXsfQAYL7PzMONASTDa0LeFSSwMy21joE3OqpuoXmVFceIMuj0RhBBAilS4gAoO0", + "dq": "Zd1XlB2w_Vlo8AL7s9wCq6yyP19OMdYp5iahZ7B3mSlcL8iJiLubBp7MQFk2SUKKBo8kdjM7ggSUlLFUZq4xyOIrEgFKVNBA4P7sdvbBBXDDpJDqkRtRw1gSGnLNR38-F7y6OPeMa0jN3aKi3GmZbGhLh1VCfvy9aNAViFvs-hE", + "qi": "xy8cIuP9Y_vwX7mOYftqv_NofI37EEBxPdqX-CeEIigflsbmaWSVADql6t-XODgK7PcbepRpxcx4AuRPBGFULvPNgEGy5YtdSSF8RwNt3GhK_d5Hh71-hs0WQ_dZ5yFMXJDTg2RpcsZwn65mN1gcc7a7qYZwciYsa1Ynmj36xmw", + "key_ops": [ + "sign" + ], + "ext": true + }, + "publicJwk": { + "kid": "it1Z6EDEV5g", + "kty": "RSA", + "alg": "RS256", + "n": "xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + } + } + }, + "jwkSet": "{\"keys\":[{\"kid\":\"ohtdSKLCdYs\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"gI5JLLhVFG8\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"1ZHTLTyLbQs\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"AVS5efNiEEM\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"ZVFUPkFyy18\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"mROehy7CZO4\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"it1Z6EDEV5g\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true}]}" + } +} \ No newline at end of file diff --git a/test/resources/patch/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json b/test/resources/patch/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json new file mode 100644 index 000000000..02a055a65 --- /dev/null +++ b/test/resources/patch/db/oidc/rp/clients/_key_https%3A%2F%2Ftim.localhost%3A7777.json @@ -0,0 +1 @@ +{"provider":{"url":"https://tim.localhost:7777","configuration":{"issuer":"https://localhost:7777","authorization_endpoint":"https://localhost:7777/authorize","token_endpoint":"https://localhost:7777/token","userinfo_endpoint":"https://localhost:7777/userinfo","jwks_uri":"https://localhost:7777/jwks","registration_endpoint":"https://localhost:7777/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7777/session","end_session_endpoint":"https://localhost:7777/logout"},"jwks":{"keys":[{"kid":"ohtdSKLCdYs","kty":"RSA","alg":"RS256","n":"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"gI5JLLhVFG8","kty":"RSA","alg":"RS384","n":"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1ZHTLTyLbQs","kty":"RSA","alg":"RS512","n":"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"AVS5efNiEEM","kty":"RSA","alg":"RS256","n":"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"ZVFUPkFyy18","kty":"RSA","alg":"RS384","n":"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"mROehy7CZO4","kty":"RSA","alg":"RS512","n":"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"it1Z6EDEV5g","kty":"RSA","alg":"RS256","n":"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"1b9d4517845e56c41288db6d4a3b7b70","client_secret":"f8114bd892a646a17c464eeb645e5ca5","redirect_uris":["https://tim.localhost:7777/api/oidc/rp/https%3A%2F%2Ftim.localhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://tim.localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://tim.localhost:7777/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiMWI5ZDQ1MTc4NDVlNTZjNDEyODhkYjZkNGEzYjdiNzAiLCJhdWQiOiIxYjlkNDUxNzg0NWU1NmM0MTI4OGRiNmQ0YTNiN2I3MCJ9.QPGpLFsq1peOIrNa1915Z2a6k6vyEhChhH6iLEx542QFx5SyOV7lkRPrWCEgz-d2y7crGjlSHtRFoazJ51hyzMXvQVBPI1ZwJIv6KzmyMHujuiVlUMcdQLUQoPjUS-muLeGHLi0gu2AmASC2xWxh-PUcbfd1HLJwOoCAhlVG1KMiU5H-3J3SiCH4CtabbzO1UHWZ4L4gIYmoSLrIUr4_AXNHdm4XP_VMRtGVh-2zeYu2vyGW_tEjRdjqbGrNgtBbMRmVJg9o4N-2rJ69xHm4gz9y8w5KbqUyQgXui7-KzvXwpd5KP0_7CD2w90fhjS0kAKZl612cMmHEeLrHcqxBSA","registration_client_uri":"https://localhost:7777/register/1b9d4517845e56c41288db6d4a3b7b70","client_id_issued_at":1498596123,"client_secret_expires_at":0}} \ No newline at end of file diff --git a/test/resources/patch/tim.localhost/index.html b/test/resources/patch/tim.localhost/index.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/patch/tim.localhost/read-only.ttl b/test/resources/patch/tim.localhost/read-only.ttl new file mode 100644 index 000000000..5d932fc61 --- /dev/null +++ b/test/resources/patch/tim.localhost/read-only.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/patch/tim.localhost/read-only.ttl.acl b/test/resources/patch/tim.localhost/read-only.ttl.acl new file mode 100644 index 000000000..7fc228254 --- /dev/null +++ b/test/resources/patch/tim.localhost/read-only.ttl.acl @@ -0,0 +1,6 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo <./read-only.ttl>; + acl:agent ; + acl:mode acl:Read. From f04b5ef3855124b132230eb80bad7b92a8d3907b Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 28 Jun 2017 11:44:22 -0400 Subject: [PATCH 071/178] Syntactically and structurally validate patches. --- lib/handlers/patch.js | 2 +- lib/handlers/patch/n3-patcher.js | 14 +++-- test/integration/patch.js | 58 ++++++++++++++++++- .../patch/tim.localhost/read-write.ttl | 1 + .../patch/tim.localhost/read-write.ttl.acl | 6 ++ 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 test/resources/patch/tim.localhost/read-write.ttl create mode 100644 test/resources/patch/tim.localhost/read-write.ttl.acl diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 128dd3afe..ad2768c6b 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -43,7 +43,7 @@ function patchHandler (req, res, next) { // Find the appropriate patcher for the given content type const patchGraph = PATCHERS[patch.contentType] if (!patchGraph) { - return next(error(415, 'Unknown patch content type: ' + patch.contentType)) + return next(error(415, 'Unsupported patch content type: ' + patch.contentType)) } // Read the RDF graph to be patched from the file diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patcher.js index 26e3d066c..273d650e6 100644 --- a/lib/handlers/patch/n3-patcher.js +++ b/lib/handlers/patch/n3-patcher.js @@ -28,6 +28,8 @@ function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { $rdf.parse(patchText, patchGraph, patchURI, 'text/n3') resolve(patchGraph) }) + .catch(err => { throw error(400, `Invalid patch document: ${err}`) }) + // Query the N3 document for insertions and deletions .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES} SELECT ?insert ?delete WHERE { @@ -35,13 +37,17 @@ function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { OPTIONAL { ?patch p:insert ?insert. } OPTIONAL { ?patch p:delete ?delete. } }`) + .catch(err => { throw error(400, `No patch for ${targetURI} found.`, err) }) ) + // Return the insertions and deletions as an rdflib patch document .then(result => { - return { - insert: result['?insert'], - delete: result['?delete'] + const inserts = result['?insert'] + const deletes = result['?delete'] + if (!inserts && !deletes) { + throw error(400, 'Patch should at least contain inserts or deletes.') } + return {insert: inserts, delete: deletes} }) } @@ -63,6 +69,6 @@ function applyPatch (patchObject, target, targetKB) { function queryForFirstResult (store, sparql) { return new Promise((resolve, reject) => { const query = $rdf.SPARQLToQuery(sparql, false, store) - store.query(query, resolve) + store.query(query, resolve, null, () => reject(new Error('No results.'))) }) } diff --git a/test/integration/patch.js b/test/integration/patch.js index 42b689469..3c8d07929 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -1,4 +1,4 @@ -// Integration tests for PATCH +// Integration tests for PATCH with text/n3 const assert = require('chai').assert const ldnode = require('../../index') const path = require('path') @@ -43,6 +43,62 @@ describe('PATCH', () => { }) ) }) + + describe('with an unsupported request content type', () => { + it('returns a 415', () => + request.patch(`/read-write.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/other') + .send('other content type') + .expect(415) + .then(response => { + assert.include(response.text, 'Unsupported patch content type: text/other') + }) + ) + }) + + describe('with a patch document containing invalid syntax', () => { + it('returns a 400', () => + request.patch(`/read-write.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send('invalid') + .expect(400) + .then(response => { + assert.include(response.text, 'Invalid patch document') + }) + ) + }) + + describe('with a patch document without relevant patch element', () => { + it('returns a 400', () => + request.patch(`/read-write.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> a p:Patch.` + )) + .expect(400) + .then(response => { + assert.include(response.text, 'No patch for https://tim.localhost:7777/read-write.ttl found') + }) + ) + }) + + describe('with a patch document without insert and without deletes', () => { + it('returns a 400', () => + request.patch(`/read-write.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches .` + )) + .expect(400) + .then(response => { + assert.include(response.text, 'Patch should at least contain inserts or deletes') + }) + ) + }) }) function n3Patch (contents) { diff --git a/test/resources/patch/tim.localhost/read-write.ttl b/test/resources/patch/tim.localhost/read-write.ttl new file mode 100644 index 000000000..5d932fc61 --- /dev/null +++ b/test/resources/patch/tim.localhost/read-write.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/patch/tim.localhost/read-write.ttl.acl b/test/resources/patch/tim.localhost/read-write.ttl.acl new file mode 100644 index 000000000..fb2e05e4d --- /dev/null +++ b/test/resources/patch/tim.localhost/read-write.ttl.acl @@ -0,0 +1,6 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo <./read-write.ttl>; + acl:agent ; + acl:mode acl:Read, acl:Write. From 351463d0ad1f057739fd1b21eb9ac83fb41ee7e9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 29 Jun 2017 17:33:00 -0400 Subject: [PATCH 072/178] Enable and test PATCH appending. --- lib/handlers/patch.js | 4 +- lib/ldp-middleware.js | 2 +- test/integration/patch.js | 114 +++++++++++++++--- test/resources/patch/tim.localhost/.acl | 7 ++ .../patch/tim.localhost/append-only.ttl | 1 + .../patch/tim.localhost/append-only.ttl.acl | 6 + .../patch/tim.localhost/write-only.ttl | 1 + .../patch/tim.localhost/write-only.ttl.acl | 6 + test/test-utils.js | 11 ++ 9 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 test/resources/patch/tim.localhost/.acl create mode 100644 test/resources/patch/tim.localhost/append-only.ttl create mode 100644 test/resources/patch/tim.localhost/append-only.ttl.acl create mode 100644 test/resources/patch/tim.localhost/write-only.ttl create mode 100644 test/resources/patch/tim.localhost/write-only.ttl.acl diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index ad2768c6b..c3fdd0d70 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -105,8 +105,8 @@ function writeGraph (graph, resource) { if (err) { return reject(error(500, 'Failed to write file back after patch: ' + err)) } - debug('PATCH -- applied OK (sync)') - resolve('Patch applied OK\n') + debug('PATCH -- applied successfully') + resolve('Patch applied successfully.\n') }) }) } diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js index dfcbf3e1b..89461743c 100644 --- a/lib/ldp-middleware.js +++ b/lib/ldp-middleware.js @@ -24,7 +24,7 @@ function LdpMiddleware (corsSettings) { router.copy('/*', acl.allow('Write'), copy) router.get('/*', index, acl.allow('Read'), get) router.post('/*', acl.allow('Append'), post) - router.patch('/*', acl.allow('Write'), patch) + router.patch('/*', acl.allow('Append'), patch) router.put('/*', acl.allow('Write'), put) router.delete('/*', acl.allow('Write'), del) diff --git a/test/integration/patch.js b/test/integration/patch.js index 3c8d07929..cbc12110a 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -1,13 +1,15 @@ // Integration tests for PATCH with text/n3 -const assert = require('chai').assert +const { assert } = require('chai') const ldnode = require('../../index') const path = require('path') const supertest = require('supertest') +const { read, rm, backup, restore } = require('../test-utils') // Server settings const port = 7777 const serverUri = `https://tim.localhost:${port}` const root = path.join(__dirname, '../resources/patch') +const filePath = 'patch/tim.localhost' const serverOptions = { serverUri, root, @@ -28,22 +30,6 @@ describe('PATCH', () => { request = supertest(serverUri) }) - describe('on a resource to which the user has read-only access', () => { - it('returns a 403', () => - request.patch(`/read-only.ttl`) - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> a p:Patch; - p:insert { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - }) - describe('with an unsupported request content type', () => { it('returns a 415', () => request.patch(`/read-write.ttl`) @@ -99,6 +85,100 @@ describe('PATCH', () => { }) ) }) + + describe('appending', () => { + describe('to a resource with read-only access', () => { + it('returns a 403', () => + request.patch(`/read-only.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read(`${filePath}/read-only.ttl`), + ' .\n') + }) + }) + + describe('to a non-existing file', () => { + after(() => rm(`${filePath}/new.ttl`)) + + it('returns a 200', () => + request.patch(`/new.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('creates the file', () => { + assert.equal(read(`${filePath}/new.ttl`), + '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('to a resource with append access', () => { + before(() => backup(`${filePath}/append-only.ttl`)) + after(() => restore(`${filePath}/append-only.ttl`)) + + it('returns a 200', () => + request.patch(`/append-only.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read(`${filePath}/append-only.ttl`), + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('to a resource with write access', () => { + before(() => backup(`${filePath}/write-only.ttl`)) + after(() => restore(`${filePath}/write-only.ttl`)) + + it('returns a 200', () => + request.patch(`/write-only.ttl`) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read(`${filePath}/write-only.ttl`), + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') + }) + }) + }) }) function n3Patch (contents) { diff --git a/test/resources/patch/tim.localhost/.acl b/test/resources/patch/tim.localhost/.acl new file mode 100644 index 000000000..8df5285c2 --- /dev/null +++ b/test/resources/patch/tim.localhost/.acl @@ -0,0 +1,7 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo ; + acl:defaultForNew ; + acl:agent ; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/test/resources/patch/tim.localhost/append-only.ttl b/test/resources/patch/tim.localhost/append-only.ttl new file mode 100644 index 000000000..5d932fc61 --- /dev/null +++ b/test/resources/patch/tim.localhost/append-only.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/patch/tim.localhost/append-only.ttl.acl b/test/resources/patch/tim.localhost/append-only.ttl.acl new file mode 100644 index 000000000..2e5fac880 --- /dev/null +++ b/test/resources/patch/tim.localhost/append-only.ttl.acl @@ -0,0 +1,6 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo <./append-only.ttl>; + acl:agent ; + acl:mode acl:Append. diff --git a/test/resources/patch/tim.localhost/write-only.ttl b/test/resources/patch/tim.localhost/write-only.ttl new file mode 100644 index 000000000..5d932fc61 --- /dev/null +++ b/test/resources/patch/tim.localhost/write-only.ttl @@ -0,0 +1 @@ + . diff --git a/test/resources/patch/tim.localhost/write-only.ttl.acl b/test/resources/patch/tim.localhost/write-only.ttl.acl new file mode 100644 index 000000000..e41300100 --- /dev/null +++ b/test/resources/patch/tim.localhost/write-only.ttl.acl @@ -0,0 +1,6 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo <./write-only.ttl>; + acl:agent ; + acl:mode acl:Write. diff --git a/test/test-utils.js b/test/test-utils.js index 41d3d584e..2429a75fd 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -22,3 +22,14 @@ exports.read = function (file) { 'encoding': 'utf8' }) } + +// Backs up the given file +exports.backup = function (src) { + exports.cp(src, src + '.bak') +} + +// Restores a backup of the given file +exports.restore = function (src) { + exports.cp(src + '.bak', src) + exports.rm(src + '.bak') +} From 338795ee825163f2976535e2196b171e1cc69d23 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 10:55:13 -0400 Subject: [PATCH 073/178] Use single-user setup for PATCH tests. --- test/integration/patch.js | 37 +++++++++---------- test/resources/patch/{tim.localhost => }/.acl | 0 .../patch/{tim.localhost => }/append-only.ttl | 0 .../{tim.localhost => }/append-only.ttl.acl | 0 .../patch/{tim.localhost => }/index.html | 0 .../patch/{tim.localhost => }/read-only.ttl | 0 .../{tim.localhost => }/read-only.ttl.acl | 0 .../patch/{tim.localhost => }/read-write.ttl | 0 .../{tim.localhost => }/read-write.ttl.acl | 0 .../patch/{tim.localhost => }/write-only.ttl | 0 .../{tim.localhost => }/write-only.ttl.acl | 0 11 files changed, 18 insertions(+), 19 deletions(-) rename test/resources/patch/{tim.localhost => }/.acl (100%) rename test/resources/patch/{tim.localhost => }/append-only.ttl (100%) rename test/resources/patch/{tim.localhost => }/append-only.ttl.acl (100%) rename test/resources/patch/{tim.localhost => }/index.html (100%) rename test/resources/patch/{tim.localhost => }/read-only.ttl (100%) rename test/resources/patch/{tim.localhost => }/read-only.ttl.acl (100%) rename test/resources/patch/{tim.localhost => }/read-write.ttl (100%) rename test/resources/patch/{tim.localhost => }/read-write.ttl.acl (100%) rename test/resources/patch/{tim.localhost => }/write-only.ttl (100%) rename test/resources/patch/{tim.localhost => }/write-only.ttl.acl (100%) diff --git a/test/integration/patch.js b/test/integration/patch.js index cbc12110a..7ee168284 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -9,7 +9,6 @@ const { read, rm, backup, restore } = require('../test-utils') const port = 7777 const serverUri = `https://tim.localhost:${port}` const root = path.join(__dirname, '../resources/patch') -const filePath = 'patch/tim.localhost' const serverOptions = { serverUri, root, @@ -17,7 +16,7 @@ const serverOptions = { sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - idp: true + idp: false } const userCredentials = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IkFWUzVlZk5pRUVNIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiaHR0cHM6Ly90aW0ubG9jYWxob3N0Ojc3NzcvcHJvZmlsZS9jYXJkI21lIiwiYXVkIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJleHAiOjc3OTkyMjkwMDksImlhdCI6MTQ5MjAyOTAwOSwianRpIjoiZWY3OGQwYjY3ZWRjNzJhMSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.H9lxCbNc47SfIq3hhHnj48BE-YFnvhCfDH9Jc4PptApTEip8sVj0E_u704K_huhNuWBvuv3cDRDGYZM7CuLnzgJG1BI75nXR9PYAJPK9Ketua2KzIrftNoyKNamGqkoCKFafF4z_rsmtXQ5u1_60SgWRcouXMpcHnnDqINF1JpvS21xjE_LbJ6qgPEhu3rRKcv1hpRdW9dRvjtWb9xu84bAjlRuT02lyDBHgj2utxpE_uqCbj48qlee3GoqWpGkSS-vJ6JA0aWYgnyv8fQsxf9rpdFNzKRoQO6XYMy6niEKj8aKgxjaUlpoGGJ5XtVLHH8AGwjYXR8iznYzJvEcB7Q' @@ -32,7 +31,7 @@ describe('PATCH', () => { describe('with an unsupported request content type', () => { it('returns a 415', () => - request.patch(`/read-write.ttl`) + request.patch('/read-write.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/other') .send('other content type') @@ -45,7 +44,7 @@ describe('PATCH', () => { describe('with a patch document containing invalid syntax', () => { it('returns a 400', () => - request.patch(`/read-write.ttl`) + request.patch('/read-write.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send('invalid') @@ -58,7 +57,7 @@ describe('PATCH', () => { describe('with a patch document without relevant patch element', () => { it('returns a 400', () => - request.patch(`/read-write.ttl`) + request.patch('/read-write.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -73,7 +72,7 @@ describe('PATCH', () => { describe('with a patch document without insert and without deletes', () => { it('returns a 400', () => - request.patch(`/read-write.ttl`) + request.patch('/read-write.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -89,7 +88,7 @@ describe('PATCH', () => { describe('appending', () => { describe('to a resource with read-only access', () => { it('returns a 403', () => - request.patch(`/read-only.ttl`) + request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -103,16 +102,16 @@ describe('PATCH', () => { ) it('does not modify the file', () => { - assert.equal(read(`${filePath}/read-only.ttl`), + assert.equal(read('patch/read-only.ttl'), ' .\n') }) }) describe('to a non-existing file', () => { - after(() => rm(`${filePath}/new.ttl`)) + after(() => rm('patch/new.ttl')) it('returns a 200', () => - request.patch(`/new.ttl`) + request.patch('/new.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -126,17 +125,17 @@ describe('PATCH', () => { ) it('creates the file', () => { - assert.equal(read(`${filePath}/new.ttl`), + assert.equal(read('patch/new.ttl'), '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') }) }) describe('to a resource with append access', () => { - before(() => backup(`${filePath}/append-only.ttl`)) - after(() => restore(`${filePath}/append-only.ttl`)) + before(() => backup('patch/append-only.ttl')) + after(() => restore('patch/append-only.ttl')) it('returns a 200', () => - request.patch(`/append-only.ttl`) + request.patch('/append-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -150,17 +149,17 @@ describe('PATCH', () => { ) it('patches the file', () => { - assert.equal(read(`${filePath}/append-only.ttl`), + assert.equal(read('patch/append-only.ttl'), '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') }) }) describe('to a resource with write access', () => { - before(() => backup(`${filePath}/write-only.ttl`)) - after(() => restore(`${filePath}/write-only.ttl`)) + before(() => backup('patch/write-only.ttl')) + after(() => restore('patch/write-only.ttl')) it('returns a 200', () => - request.patch(`/write-only.ttl`) + request.patch('/write-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` @@ -174,7 +173,7 @@ describe('PATCH', () => { ) it('patches the file', () => { - assert.equal(read(`${filePath}/write-only.ttl`), + assert.equal(read('patch/write-only.ttl'), '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') }) }) diff --git a/test/resources/patch/tim.localhost/.acl b/test/resources/patch/.acl similarity index 100% rename from test/resources/patch/tim.localhost/.acl rename to test/resources/patch/.acl diff --git a/test/resources/patch/tim.localhost/append-only.ttl b/test/resources/patch/append-only.ttl similarity index 100% rename from test/resources/patch/tim.localhost/append-only.ttl rename to test/resources/patch/append-only.ttl diff --git a/test/resources/patch/tim.localhost/append-only.ttl.acl b/test/resources/patch/append-only.ttl.acl similarity index 100% rename from test/resources/patch/tim.localhost/append-only.ttl.acl rename to test/resources/patch/append-only.ttl.acl diff --git a/test/resources/patch/tim.localhost/index.html b/test/resources/patch/index.html similarity index 100% rename from test/resources/patch/tim.localhost/index.html rename to test/resources/patch/index.html diff --git a/test/resources/patch/tim.localhost/read-only.ttl b/test/resources/patch/read-only.ttl similarity index 100% rename from test/resources/patch/tim.localhost/read-only.ttl rename to test/resources/patch/read-only.ttl diff --git a/test/resources/patch/tim.localhost/read-only.ttl.acl b/test/resources/patch/read-only.ttl.acl similarity index 100% rename from test/resources/patch/tim.localhost/read-only.ttl.acl rename to test/resources/patch/read-only.ttl.acl diff --git a/test/resources/patch/tim.localhost/read-write.ttl b/test/resources/patch/read-write.ttl similarity index 100% rename from test/resources/patch/tim.localhost/read-write.ttl rename to test/resources/patch/read-write.ttl diff --git a/test/resources/patch/tim.localhost/read-write.ttl.acl b/test/resources/patch/read-write.ttl.acl similarity index 100% rename from test/resources/patch/tim.localhost/read-write.ttl.acl rename to test/resources/patch/read-write.ttl.acl diff --git a/test/resources/patch/tim.localhost/write-only.ttl b/test/resources/patch/write-only.ttl similarity index 100% rename from test/resources/patch/tim.localhost/write-only.ttl rename to test/resources/patch/write-only.ttl diff --git a/test/resources/patch/tim.localhost/write-only.ttl.acl b/test/resources/patch/write-only.ttl.acl similarity index 100% rename from test/resources/patch/tim.localhost/write-only.ttl.acl rename to test/resources/patch/write-only.ttl.acl From 4c6c275a4c043062e1581351ab6056e2d3a9b72e Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 11:50:46 -0400 Subject: [PATCH 074/178] Test PATCH deletion. Two tests are skipped; these require an ACL check inside of the handler. --- lib/handlers/patch/n3-patcher.js | 2 +- test/integration/patch.js | 161 +++++++++++++++++++++++++-- test/resources/patch/append-only.ttl | 1 + test/resources/patch/read-only.ttl | 1 + test/resources/patch/read-write.ttl | 1 + test/resources/patch/write-only.ttl | 1 + 6 files changed, 159 insertions(+), 8 deletions(-) diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patcher.js index 273d650e6..dbc8e308e 100644 --- a/lib/handlers/patch/n3-patcher.js +++ b/lib/handlers/patch/n3-patcher.js @@ -58,7 +58,7 @@ function applyPatch (patchObject, target, targetKB) { if (err) { const message = err.message || err // returns string at the moment debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return reject(error(409, 'Error when applying the patch')) + return reject(error(409, `The patch could not be applied. ${message}`)) } resolve(targetKB) }) diff --git a/test/integration/patch.js b/test/integration/patch.js index 7ee168284..82ba2dcde 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -3,6 +3,7 @@ const { assert } = require('chai') const ldnode = require('../../index') const path = require('path') const supertest = require('supertest') +const fs = require('fs') const { read, rm, backup, restore } = require('../test-utils') // Server settings @@ -103,7 +104,7 @@ describe('PATCH', () => { it('does not modify the file', () => { assert.equal(read('patch/read-only.ttl'), - ' .\n') + ' .\n .\n') }) }) @@ -116,7 +117,7 @@ describe('PATCH', () => { .set('Content-Type', 'text/n3') .send(n3Patch(` <> p:patches ; - p:insert { . }.` + p:insert { . }.` )) .expect(200) .then(response => { @@ -126,7 +127,7 @@ describe('PATCH', () => { it('creates the file', () => { assert.equal(read('patch/new.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') + '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n') }) }) @@ -140,7 +141,7 @@ describe('PATCH', () => { .set('Content-Type', 'text/n3') .send(n3Patch(` <> p:patches ; - p:insert { . }.` + p:insert { . }.` )) .expect(200) .then(response => { @@ -150,7 +151,7 @@ describe('PATCH', () => { it('patches the file', () => { assert.equal(read('patch/append-only.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') }) }) @@ -164,7 +165,7 @@ describe('PATCH', () => { .set('Content-Type', 'text/n3') .send(n3Patch(` <> p:patches ; - p:insert { . }.` + p:insert { . }.` )) .expect(200) .then(response => { @@ -174,7 +175,153 @@ describe('PATCH', () => { it('patches the file', () => { assert.equal(read('patch/write-only.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\n') + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') + }) + }) + }) + + describe('deleting', () => { + describe('from a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') + }) + }) + + describe('from a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) + }) + }) + + describe.skip('from a resource with append-only access', () => { + before(() => backup('patch/append-only.ttl')) + after(() => restore('patch/append-only.ttl')) + + it('returns a 403', () => + request.patch('/append-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe.skip('from a resource with write-only access', () => { + before(() => backup('patch/write-only.ttl')) + after(() => restore('patch/write-only.ttl')) + + // Allowing the delete would either return 200 or 409, + // thereby incorrectly giving the user (guess-based) read access; + // therefore, we need to return 403. + it('returns a 403', () => + request.patch('/write-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe('from a resource with read-write access', () => { + describe('with a patch for existing data', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 200', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read('patch/read-write.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('with a patch for non-existing data', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 409', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-write.ttl'), + ' .\n .\n') + }) }) }) }) diff --git a/test/resources/patch/append-only.ttl b/test/resources/patch/append-only.ttl index 5d932fc61..a63c5246e 100644 --- a/test/resources/patch/append-only.ttl +++ b/test/resources/patch/append-only.ttl @@ -1 +1,2 @@ . + . diff --git a/test/resources/patch/read-only.ttl b/test/resources/patch/read-only.ttl index 5d932fc61..a63c5246e 100644 --- a/test/resources/patch/read-only.ttl +++ b/test/resources/patch/read-only.ttl @@ -1 +1,2 @@ . + . diff --git a/test/resources/patch/read-write.ttl b/test/resources/patch/read-write.ttl index 5d932fc61..a63c5246e 100644 --- a/test/resources/patch/read-write.ttl +++ b/test/resources/patch/read-write.ttl @@ -1 +1,2 @@ . + . diff --git a/test/resources/patch/write-only.ttl b/test/resources/patch/write-only.ttl index 5d932fc61..a63c5246e 100644 --- a/test/resources/patch/write-only.ttl +++ b/test/resources/patch/write-only.ttl @@ -1 +1,2 @@ . + . From 1d55d0ec6108eef6d901838c58a1d8986da1fc88 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 14:18:31 -0400 Subject: [PATCH 075/178] Test PATCH combined deletion and insertion. --- test/integration/patch.js | 185 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 9 deletions(-) diff --git a/test/integration/patch.js b/test/integration/patch.js index 82ba2dcde..488696743 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -87,7 +87,7 @@ describe('PATCH', () => { }) describe('appending', () => { - describe('to a resource with read-only access', () => { + describe('on a resource with read-only access', () => { it('returns a 403', () => request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) @@ -108,7 +108,7 @@ describe('PATCH', () => { }) }) - describe('to a non-existing file', () => { + describe('on a non-existing file', () => { after(() => rm('patch/new.ttl')) it('returns a 200', () => @@ -131,7 +131,7 @@ describe('PATCH', () => { }) }) - describe('to a resource with append access', () => { + describe('on a resource with append access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -155,7 +155,7 @@ describe('PATCH', () => { }) }) - describe('to a resource with write access', () => { + describe('on a resource with write access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -181,7 +181,7 @@ describe('PATCH', () => { }) describe('deleting', () => { - describe('from a resource with read-only access', () => { + describe('on a resource with read-only access', () => { it('returns a 403', () => request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) @@ -202,7 +202,7 @@ describe('PATCH', () => { }) }) - describe('from a non-existing file', () => { + describe('on a non-existing file', () => { after(() => rm('patch/new.ttl')) it('returns a 409', () => @@ -224,7 +224,7 @@ describe('PATCH', () => { }) }) - describe.skip('from a resource with append-only access', () => { + describe.skip('on a resource with append-only access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -248,7 +248,7 @@ describe('PATCH', () => { }) }) - describe.skip('from a resource with write-only access', () => { + describe.skip('on a resource with write-only access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -275,7 +275,7 @@ describe('PATCH', () => { }) }) - describe('from a resource with read-write access', () => { + describe('on a resource with read-write access', () => { describe('with a patch for existing data', () => { before(() => backup('patch/read-write.ttl')) after(() => restore('patch/read-write.ttl')) @@ -325,6 +325,173 @@ describe('PATCH', () => { }) }) }) + + describe('deleting and inserting', () => { + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') + }) + }) + + describe('on a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) + }) + }) + + describe.skip('on a resource with append-only access', () => { + before(() => backup('patch/append-only.ttl')) + after(() => restore('patch/append-only.ttl')) + + it('returns a 403', () => + request.patch('/append-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe.skip('on a resource with write-only access', () => { + before(() => backup('patch/write-only.ttl')) + after(() => restore('patch/write-only.ttl')) + + // Allowing the delete would either return 200 or 409, + // thereby incorrectly giving the user (guess-based) read access; + // therefore, we need to return 403. + it('returns a 403', () => + request.patch('/write-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe('on a resource with read-write access', () => { + describe('with a patch for existing data', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 200', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read('patch/read-write.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') + }) + }) + + describe('with a patch for non-existing data', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 409', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-write.ttl'), + ' .\n .\n') + }) + }) + + it('executes deletes before inserts', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + }) + }) }) function n3Patch (contents) { From c463d73cf16a7a06f72b9f18bd49a549debc2180 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 16:26:41 -0400 Subject: [PATCH 076/178] Add WHERE support to N3 patches. --- lib/handlers/patch/n3-patcher.js | 10 +- test/integration/patch.js | 290 +++++++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 23 deletions(-) diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patcher.js index dbc8e308e..29808c065 100644 --- a/lib/handlers/patch/n3-patcher.js +++ b/lib/handlers/patch/n3-patcher.js @@ -32,22 +32,22 @@ function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { // Query the N3 document for insertions and deletions .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES} - SELECT ?insert ?delete WHERE { + SELECT ?insert ?delete ?where WHERE { ?patch p:patches <${targetURI}>. OPTIONAL { ?patch p:insert ?insert. } OPTIONAL { ?patch p:delete ?delete. } + OPTIONAL { ?patch p:where ?where. } }`) .catch(err => { throw error(400, `No patch for ${targetURI} found.`, err) }) ) // Return the insertions and deletions as an rdflib patch document .then(result => { - const inserts = result['?insert'] - const deletes = result['?delete'] - if (!inserts && !deletes) { + const {'?insert': insert, '?delete': deleted, '?where': where} = result + if (!insert && !deleted) { throw error(400, 'Patch should at least contain inserts or deletes.') } - return {insert: inserts, delete: deletes} + return {insert, delete: deleted, where} }) } diff --git a/test/integration/patch.js b/test/integration/patch.js index 488696743..0a2758c3e 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -180,6 +180,158 @@ describe('PATCH', () => { }) }) + describe('inserting (= append with WHERE)', () => { + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') + }) + }) + + describe('on a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) + }) + }) + + describe.skip('on a resource with append-only access', () => { + before(() => backup('patch/append-only.ttl')) + after(() => restore('patch/append-only.ttl')) + + it('returns a 403', () => + request.patch('/append-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe.skip('on a resource with write-only access', () => { + before(() => backup('patch/write-only.ttl')) + after(() => restore('patch/write-only.ttl')) + + // Allowing the delete would either return 200 or 409, + // thereby incorrectly giving the user (guess-based) read access; + // therefore, we need to return 403. + it('returns a 403', () => + request.patch('/write-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/append-only.ttl'), + ' .\n .\n') + }) + }) + + describe('on a resource with read-write access', () => { + describe('with a matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 200', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read('patch/read-write.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('with a non-matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 409', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-write.ttl'), + ' .\n .\n') + }) + }) + }) + }) + describe('deleting', () => { describe('on a resource with read-only access', () => { it('returns a 403', () => @@ -323,10 +475,60 @@ describe('PATCH', () => { ' .\n .\n') }) }) + + describe('with a matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 200', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:delete { ?a . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read('patch/read-write.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('with a non-matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 409', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:delete { ?a . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-write.ttl'), + ' .\n .\n') + }) + }) }) }) - describe('deleting and inserting', () => { + describe('deleting and appending/inserting', () => { describe('on a resource with read-only access', () => { it('returns a 403', () => request.patch('/read-only.ttl') @@ -426,6 +628,21 @@ describe('PATCH', () => { }) describe('on a resource with read-write access', () => { + it('executes deletes before inserts', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + describe('with a patch for existing data', () => { before(() => backup('patch/read-write.ttl')) after(() => restore('patch/read-write.ttl')) @@ -436,8 +653,8 @@ describe('PATCH', () => { .set('Content-Type', 'text/n3') .send(n3Patch(` <> p:patches ; - p:insert { . }; - p:delete { . }.` + p:insert { . }; + p:delete { . }.` )) .expect(200) .then(response => { @@ -461,8 +678,8 @@ describe('PATCH', () => { .set('Content-Type', 'text/n3') .send(n3Patch(` <> p:patches ; - p:insert { . }; - p:delete { . }.` + p:insert { . }; + p:delete { . }.` )) .expect(409) .then(response => { @@ -476,20 +693,57 @@ describe('PATCH', () => { }) }) - it('executes deletes before inserts', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) + describe('with a matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 200', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }; + p:delete { ?a . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) ) + + it('patches the file', () => { + assert.equal(read('patch/read-write.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:a tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('with a non-matching WHERE clause', () => { + before(() => backup('patch/read-write.ttl')) + after(() => restore('patch/read-write.ttl')) + + it('returns a 409', () => + request.patch('/read-write.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }; + p:delete { ?a . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-write.ttl'), + ' .\n .\n') + }) + }) }) }) }) From d4315b4b98f64b9d7df363de1794b0c8e1756c42 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 22:58:32 -0400 Subject: [PATCH 077/178] Refactor patch handler to perform everything but parsing. --- lib/handlers/patch.js | 51 ++++++++++++------- .../{n3-patcher.js => n3-patch-parser.js} | 34 ++----------- lib/handlers/patch/sparql-update-parser.js | 18 +++++++ lib/handlers/patch/sparql-update-patcher.js | 43 ---------------- test/integration/patch.js | 2 +- 5 files changed, 57 insertions(+), 91 deletions(-) rename lib/handlers/patch/{n3-patcher.js => n3-patch-parser.js} (57%) create mode 100644 lib/handlers/patch/sparql-update-parser.js delete mode 100644 lib/handlers/patch/sparql-update-patcher.js diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index c3fdd0d70..ea4cae7f0 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -13,20 +13,20 @@ const crypto = require('crypto') const DEFAULT_TARGET_TYPE = 'text/turtle' -// Patch handlers by request body content type -const PATCHERS = { - 'application/sparql-update': require('./patch/sparql-update-patcher.js'), - 'text/n3': require('./patch/n3-patcher.js') +// Patch parsers by request body content type +const PATCH_PARSERS = { + 'application/sparql-update': require('./patch/sparql-update-parser.js'), + 'text/n3': require('./patch/n3-patch-parser.js') } // Handles a PATCH request function patchHandler (req, res, next) { - debug('PATCH -- ' + req.originalUrl) + debug(`PATCH -- ${req.originalUrl}`) res.header('MS-Author-Via', 'SPARQL') // Obtain details of the target resource const ldp = req.app.locals.ldp - const root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' + const root = !ldp.idp ? ldp.root : `${ldp.root}${req.hostname}/` const target = {} target.file = utils.uriToFilename(req.path, root) target.uri = utils.uriAbs(req) + req.originalUrl @@ -39,17 +39,18 @@ function patchHandler (req, res, next) { patch.uri = `${target.uri}#patch-${hash(patch.text)}` patch.contentType = (req.get('content-type') || '').match(/^[^;\s]*/)[0] debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) - - // Find the appropriate patcher for the given content type - const patchGraph = PATCHERS[patch.contentType] - if (!patchGraph) { - return next(error(415, 'Unsupported patch content type: ' + patch.contentType)) + const parsePatch = PATCH_PARSERS[patch.contentType] + if (!parsePatch) { + return next(error(415, `Unsupported patch content type: ${patch.contentType}`)) } - // Read the RDF graph to be patched from the file - readGraph(target) + // Parse the target graph and the patch document + Promise.all([ + readGraph(target), + parsePatch(target.uri, patch.uri, patch.text) + ]) // Patch the graph and write it back to the file - .then(graph => patchGraph(graph, target.uri, patch.uri, patch.text)) + .then(([graph, patchObject]) => applyPatch(patchObject, graph, target)) .then(graph => writeGraph(graph, target)) // Send the result to the client .then(result => { res.send(result) }) @@ -74,7 +75,7 @@ function readGraph (resource) { fileContents = '' // Fail on all other errors } else { - return reject(error(500, 'Patch: Original file read error:' + err)) + return reject(error(500, `Original file read error: ${err}`)) } } debug('PATCH -- Read target file (%d bytes)', fileContents.length) @@ -88,22 +89,38 @@ function readGraph (resource) { try { $rdf.parse(fileContents, graph, resource.uri, resource.contentType) } catch (err) { - throw error(500, 'Patch: Target ' + resource.contentType + ' file syntax error:' + err) + throw error(500, `Patch: Target ${resource.contentType} file syntax error: ${err}`) } debug('PATCH -- Parsed target file') return graph }) } +// Applies the patch to the RDF graph +function applyPatch (patchObject, graph, target) { + debug('PATCH -- Applying patch') + return new Promise((resolve, reject) => + graph.applyPatch(patchObject, graph.sym(target.uri), (err) => { + if (err) { + const message = err.message || err // returns string at the moment + debug(`PATCH -- FAILED. Returning 409. Message: '${message}'`) + return reject(error(409, `The patch could not be applied. ${message}`)) + } + resolve(graph) + }) + ) +} + // Writes the RDF graph to the given resource function writeGraph (graph, resource) { + debug('PATCH -- Writing patched file') return new Promise((resolve, reject) => { const resourceSym = graph.sym(resource.uri) const serialized = $rdf.serialize(resourceSym, graph, resource.uri, resource.contentType) fs.writeFile(resource.file, serialized, {encoding: 'utf8'}, function (err) { if (err) { - return reject(error(500, 'Failed to write file back after patch: ' + err)) + return reject(error(500, `Failed to write file after patch: ${err}`)) } debug('PATCH -- applied successfully') resolve('Patch applied successfully.\n') diff --git a/lib/handlers/patch/n3-patcher.js b/lib/handlers/patch/n3-patch-parser.js similarity index 57% rename from lib/handlers/patch/n3-patcher.js rename to lib/handlers/patch/n3-patch-parser.js index 29808c065..fe2cf3e0c 100644 --- a/lib/handlers/patch/n3-patcher.js +++ b/lib/handlers/patch/n3-patch-parser.js @@ -1,34 +1,22 @@ -// Performs a text/n3 patch on a graph +// Parses a text/n3 patch -module.exports = patch +module.exports = parsePatchDocument const $rdf = require('rdflib') -const debug = require('../../debug').handlers const error = require('../../http-error') const PATCH_NS = 'http://example.org/patch#' const PREFIXES = `PREFIX p: <${PATCH_NS}>\n` -// Patches the given graph -function patch (targetKB, targetURI, patchURI, patchText) { - const patchKB = $rdf.graph() - const target = patchKB.sym(targetURI) - - return parsePatchDocument(targetURI, patchURI, patchText, patchKB) - .then(patchObject => applyPatch(patchObject, target, targetKB)) -} - // Parses the given N3 patch document -function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { - debug('PATCH -- Parsing patch...') - +function parsePatchDocument (targetURI, patchURI, patchText) { // Parse the N3 document into triples return new Promise((resolve, reject) => { const patchGraph = $rdf.graph() $rdf.parse(patchText, patchGraph, patchURI, 'text/n3') resolve(patchGraph) }) - .catch(err => { throw error(400, `Invalid patch document: ${err}`) }) + .catch(err => { throw error(400, `Patch document syntax error: ${err}`) }) // Query the N3 document for insertions and deletions .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES} @@ -51,20 +39,6 @@ function parsePatchDocument (targetURI, patchURI, patchText, patchKB) { }) } -// Applies the patch to the target graph -function applyPatch (patchObject, target, targetKB) { - return new Promise((resolve, reject) => - targetKB.applyPatch(patchObject, target, (err) => { - if (err) { - const message = err.message || err // returns string at the moment - debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return reject(error(409, `The patch could not be applied. ${message}`)) - } - resolve(targetKB) - }) - ) -} - // Queries the store with the given SPARQL query and returns the first result function queryForFirstResult (store, sparql) { return new Promise((resolve, reject) => { diff --git a/lib/handlers/patch/sparql-update-parser.js b/lib/handlers/patch/sparql-update-parser.js new file mode 100644 index 000000000..365e9c82d --- /dev/null +++ b/lib/handlers/patch/sparql-update-parser.js @@ -0,0 +1,18 @@ +// Parses an application/sparql-update patch + +module.exports = parsePatchDocument + +const $rdf = require('rdflib') +const error = require('../../http-error') + +// Parses the given SPARQL UPDATE document +function parsePatchDocument (targetURI, patchURI, patchText) { + return new Promise((resolve, reject) => { + const baseURI = patchURI.replace(/#.*/, '') + try { + resolve($rdf.sparqlUpdateParser(patchText, $rdf.graph(), baseURI)) + } catch (err) { + reject(error(400, `Patch document syntax error: ${err}`)) + } + }) +} diff --git a/lib/handlers/patch/sparql-update-patcher.js b/lib/handlers/patch/sparql-update-patcher.js deleted file mode 100644 index 2544fe2f1..000000000 --- a/lib/handlers/patch/sparql-update-patcher.js +++ /dev/null @@ -1,43 +0,0 @@ -// Performs an application/sparql-update patch on a graph - -module.exports = patch - -const $rdf = require('rdflib') -const debug = require('../../debug').handlers -const error = require('../../http-error') - -// Patches the given graph -function patch (targetKB, targetURI, patchURI, patchText) { - const patchKB = $rdf.graph() - const target = patchKB.sym(targetURI) - - return parsePatchDocument(patchURI, patchText, patchKB) - .then(patchObject => applyPatch(patchObject, target, targetKB)) -} - -// Parses the given SPARQL UPDATE document -function parsePatchDocument (patchURI, patchText, patchKB) { - debug('PATCH -- Parsing patch...') - return new Promise((resolve, reject) => { - const baseURI = patchURI.replace(/#.*/, '') - try { - resolve($rdf.sparqlUpdateParser(patchText, patchKB, baseURI)) - } catch (err) { - reject(error(400, 'Patch format syntax error:\n' + err + '\n')) - } - }) -} - -// Applies the patch to the target graph -function applyPatch (patchObject, target, targetKB) { - return new Promise((resolve, reject) => - targetKB.applyPatch(patchObject, target, (err) => { - if (err) { - const message = err.message || err // returns string at the moment - debug('PATCH FAILED. Returning 409. Message: \'' + message + '\'') - return reject(error(409, 'Error when applying the patch')) - } - resolve(targetKB) - }) - ) -} diff --git a/test/integration/patch.js b/test/integration/patch.js index 0a2758c3e..fe8d936c2 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -51,7 +51,7 @@ describe('PATCH', () => { .send('invalid') .expect(400) .then(response => { - assert.include(response.text, 'Invalid patch document') + assert.include(response.text, 'Patch document syntax error') }) ) }) From 2a895f67126f5159e75cbfecdad7df229fe9f464 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 20:59:46 -0400 Subject: [PATCH 078/178] Expose ACL and user ID on request. The PATCH handler needs ACL for detailed permission checking, as the full set of needed permissions is unknown until the patch document has been parsed. --- lib/handlers/allow.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 5487d909a..dcc709065 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -27,9 +27,11 @@ function allow (mode) { suffix: ldp.suffixAcl, strictOrigin: ldp.strictOrigin }) + req.acl = acl getUserId(req, function (err, userId) { if (err) return next(err) + req.userId = userId var reqPath = res && res.locals && res.locals.path ? res.locals.path From 9a3597b45c7466a324dd7497c53352892cf44a58 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Fri, 30 Jun 2017 23:39:53 -0400 Subject: [PATCH 079/178] Verify read and write permissions for patches. --- lib/handlers/patch.js | 34 ++- test/integration/patch.js | 267 +++++++++++++++-------- test/resources/patch/read-append.ttl | 2 + test/resources/patch/read-append.ttl.acl | 6 + 4 files changed, 222 insertions(+), 87 deletions(-) create mode 100644 test/resources/patch/read-append.ttl create mode 100644 test/resources/patch/read-append.ttl.acl diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index ea4cae7f0..4a122dd8c 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -44,10 +44,12 @@ function patchHandler (req, res, next) { return next(error(415, `Unsupported patch content type: ${patch.contentType}`)) } - // Parse the target graph and the patch document + // Parse the target graph and the patch document, + // and verify permission for performing this specific patch Promise.all([ readGraph(target), parsePatch(target.uri, patch.uri, patch.text) + .then(patchObject => checkPermission(target, req, patchObject)) ]) // Patch the graph and write it back to the file .then(([graph, patchObject]) => applyPatch(patchObject, graph, target)) @@ -96,6 +98,36 @@ function readGraph (resource) { }) } +// Verifies whether the user is allowed to perform the patch on the target +function checkPermission (target, request, patchObject) { + // If no ACL object was passed down, assume permissions are okay. + if (!request.acl) return Promise.resolve(patchObject) + // At this point, we already assume append access, + // as this can be checked upfront before parsing the patch. + // Now that we know the details of the patch, + // we might need to perform additional checks. + var checks = [] + // Read access is required for DELETE and WHERE. + // If we would allows users without read access, + // they could use DELETE or WHERE to trigger 200 or 409, + // and thereby guess the existence of certain triples. + // DELETE additionally requires write access. + if (patchObject.delete) { + checks = [hasPermission('Read'), hasPermission('Write')] + } else if (patchObject.where) { + checks = [hasPermission('Read')] + } + return Promise.all(checks).then(() => patchObject) + + // Checks whether the user has the given permission on the target + function hasPermission (mode) { + return new Promise((resolve, reject) => + request.acl.can(request.userId, mode, target.uri, + err => err ? reject(err) : resolve()) + ) + } +} + // Applies the patch to the RDF graph function applyPatch (patchObject, graph, target) { debug('PATCH -- Applying patch') diff --git a/test/integration/patch.js b/test/integration/patch.js index fe8d936c2..17bdd51cb 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -87,27 +87,6 @@ describe('PATCH', () => { }) describe('appending', () => { - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') - }) - }) - describe('on a non-existing file', () => { after(() => rm('patch/new.ttl')) @@ -131,7 +110,28 @@ describe('PATCH', () => { }) }) - describe('on a resource with append access', () => { + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') + }) + }) + + describe('on a resource with append-only access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -155,7 +155,7 @@ describe('PATCH', () => { }) }) - describe('on a resource with write access', () => { + describe('on a resource with write-only access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -181,52 +181,52 @@ describe('PATCH', () => { }) describe('inserting (= append with WHERE)', () => { - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') + describe('on a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:insert { ?a . }; p:where { ?a . }.` )) - .expect(403) + .expect(409) .then(response => { - assert.include(response.text, 'Access denied') + assert.include(response.text, 'The patch could not be applied') }) ) - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) }) }) - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:insert { ?a . }; p:where { ?a . }.` )) - .expect(409) + .expect(403) .then(response => { - assert.include(response.text, 'The patch could not be applied') + assert.include(response.text, 'Access denied') }) ) - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') }) }) - describe.skip('on a resource with append-only access', () => { + describe('on a resource with append-only access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -251,7 +251,7 @@ describe('PATCH', () => { }) }) - describe.skip('on a resource with write-only access', () => { + describe('on a resource with write-only access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -279,6 +279,58 @@ describe('PATCH', () => { }) }) + describe('on a resource with read-append access', () => { + describe('with a matching WHERE clause', () => { + before(() => backup('patch/read-append.ttl')) + after(() => restore('patch/read-append.ttl')) + + it('returns a 200', () => + request.patch('/read-append.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + )) + .expect(200) + .then(response => { + assert.include(response.text, 'Patch applied successfully') + }) + ) + + it('patches the file', () => { + assert.equal(read('patch/read-append.ttl'), + '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') + }) + }) + + describe('with a non-matching WHERE clause', () => { + before(() => backup('patch/read-append.ttl')) + after(() => restore('patch/read-append.ttl')) + + it('returns a 409', () => + request.patch('/read-append.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + )) + .expect(409) + .then(response => { + assert.include(response.text, 'The patch could not be applied') + }) + ) + + it('does not change the file', () => { + assert.equal(read('patch/read-append.ttl'), + ' .\n .\n') + }) + }) + }) + describe('on a resource with read-write access', () => { describe('with a matching WHERE clause', () => { before(() => backup('patch/read-write.ttl')) @@ -333,50 +385,50 @@ describe('PATCH', () => { }) describe('deleting', () => { - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') + describe('on a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:delete { . }.` )) - .expect(403) + .expect(409) .then(response => { - assert.include(response.text, 'Access denied') + assert.include(response.text, 'The patch could not be applied') }) ) - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) }) }) - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:delete { . }.` )) - .expect(409) + .expect(403) .then(response => { - assert.include(response.text, 'The patch could not be applied') + assert.include(response.text, 'Access denied') }) ) - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') }) }) - describe.skip('on a resource with append-only access', () => { + describe('on a resource with append-only access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -400,7 +452,7 @@ describe('PATCH', () => { }) }) - describe.skip('on a resource with write-only access', () => { + describe('on a resource with write-only access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -427,6 +479,27 @@ describe('PATCH', () => { }) }) + describe('on a resource with read-append access', () => { + it('returns a 403', () => + request.patch('/read-append.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-append.ttl'), + ' .\n .\n') + }) + }) + describe('on a resource with read-write access', () => { describe('with a patch for existing data', () => { before(() => backup('patch/read-write.ttl')) @@ -529,52 +602,52 @@ describe('PATCH', () => { }) describe('deleting and appending/inserting', () => { - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') + describe('on a non-existing file', () => { + after(() => rm('patch/new.ttl')) + + it('returns a 409', () => + request.patch('/new.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:insert { . }; p:delete { . }.` )) - .expect(403) + .expect(409) .then(response => { - assert.include(response.text, 'Access denied') + assert.include(response.text, 'The patch could not be applied') }) ) - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') + it('does not create the file', () => { + assert.isFalse(fs.existsSync('patch/new.ttl')) }) }) - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') + describe('on a resource with read-only access', () => { + it('returns a 403', () => + request.patch('/read-only.ttl') .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', 'text/n3') .send(n3Patch(` - <> p:patches ; + <> p:patches ; p:insert { . }; p:delete { . }.` )) - .expect(409) + .expect(403) .then(response => { - assert.include(response.text, 'The patch could not be applied') + assert.include(response.text, 'Access denied') }) ) - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) + it('does not modify the file', () => { + assert.equal(read('patch/read-only.ttl'), + ' .\n .\n') }) }) - describe.skip('on a resource with append-only access', () => { + describe('on a resource with append-only access', () => { before(() => backup('patch/append-only.ttl')) after(() => restore('patch/append-only.ttl')) @@ -599,7 +672,7 @@ describe('PATCH', () => { }) }) - describe.skip('on a resource with write-only access', () => { + describe('on a resource with write-only access', () => { before(() => backup('patch/write-only.ttl')) after(() => restore('patch/write-only.ttl')) @@ -627,6 +700,28 @@ describe('PATCH', () => { }) }) + describe('on a resource with read-append access', () => { + it('returns a 403', () => + request.patch('/read-append.ttl') + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', 'text/n3') + .send(n3Patch(` + <> p:patches ; + p:insert { . }; + p:delete { . }.` + )) + .expect(403) + .then(response => { + assert.include(response.text, 'Access denied') + }) + ) + + it('does not modify the file', () => { + assert.equal(read('patch/read-append.ttl'), + ' .\n .\n') + }) + }) + describe('on a resource with read-write access', () => { it('executes deletes before inserts', () => request.patch('/read-write.ttl') diff --git a/test/resources/patch/read-append.ttl b/test/resources/patch/read-append.ttl new file mode 100644 index 000000000..a63c5246e --- /dev/null +++ b/test/resources/patch/read-append.ttl @@ -0,0 +1,2 @@ + . + . diff --git a/test/resources/patch/read-append.ttl.acl b/test/resources/patch/read-append.ttl.acl new file mode 100644 index 000000000..70f685a04 --- /dev/null +++ b/test/resources/patch/read-append.ttl.acl @@ -0,0 +1,6 @@ +@prefix acl: . + +<#Owner> a acl:Authorization; + acl:accessTo <./read-append.ttl>; + acl:agent ; + acl:mode acl:Read, acl:Append. From d10875391abd400b0a18da0a4681e6cefb5773ac Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Sun, 2 Jul 2017 15:21:09 -0400 Subject: [PATCH 080/178] Refactor PATCH tests with helper method. Improves readability and editability, and ensures consistency of preparation/cleanup and assertions. --- test/integration/patch.js | 1190 +++++++++++++------------------------ 1 file changed, 410 insertions(+), 780 deletions(-) diff --git a/test/integration/patch.js b/test/integration/patch.js index 17bdd51cb..c715e3a7e 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -24,825 +24,455 @@ const userCredentials = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IkFWUzVlZk5pRUVNIn0.eyJpc3M describe('PATCH', () => { var request + // Start the server before(done => { const server = ldnode.createServer(serverOptions) server.listen(port, done) request = supertest(serverUri) }) - describe('with an unsupported request content type', () => { - it('returns a 415', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/other') - .send('other content type') - .expect(415) - .then(response => { - assert.include(response.text, 'Unsupported patch content type: text/other') - }) - ) + describe('with a patch document', () => { + describe('with an unsupported content type', describePatch({ + path: '/read-write.ttl', + patch: `other syntax`, + contentType: 'text/other' + }, { // expected: + status: 415, + text: 'Unsupported patch content type: text/other' + })) + + describe('containing invalid syntax', describePatch({ + path: '/read-write.ttl', + patch: `invalid syntax` + }, { // expected: + status: 400, + text: 'Patch document syntax error' + })) + + describe('without relevant patch element', describePatch({ + path: '/read-write.ttl', + patch: `<> a p:Patch.` + }, { // expected: + status: 400, + text: 'No patch for https://tim.localhost:7777/read-write.ttl found' + })) + + describe('with neither insert nor delete', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches .` + }, { // expected: + status: 400, + text: 'Patch should at least contain inserts or deletes' + })) }) - describe('with a patch document containing invalid syntax', () => { - it('returns a 400', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send('invalid') - .expect(400) - .then(response => { - assert.include(response.text, 'Patch document syntax error') - }) - ) + describe('with insert', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> p:patches ; + p:insert { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> p:patches ; + p:insert { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> p:patches ; + p:insert { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> p:patches ; + p:insert { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + })) }) - describe('with a patch document without relevant patch element', () => { - it('returns a 400', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> a p:Patch.` - )) - .expect(400) - .then(response => { - assert.include(response.text, 'No patch for https://tim.localhost:7777/read-write.ttl found') - }) - ) - }) - - describe('with a patch document without insert and without deletes', () => { - it('returns a 400', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches .` - )) - .expect(400) - .then(response => { - assert.include(response.text, 'Patch should at least contain inserts or deletes') - }) - ) - }) - - describe('appending', () => { - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 200', () => - request.patch('/new.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('creates the file', () => { - assert.equal(read('patch/new.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n') - }) - }) - - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with append-only access', () => { - before(() => backup('patch/append-only.ttl')) - after(() => restore('patch/append-only.ttl')) - - it('returns a 200', () => - request.patch('/append-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) + describe('with insert and where', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + // Allowing the insert would either return 200 or 409, + // thereby inappropriately giving the user (guess-based) read access; + // therefore, we need to return 403. + status: 403, + text: 'Access denied' + })) - it('patches the file', () => { - assert.equal(read('patch/append-only.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') - }) + describe('on a resource with read-append access', () => { + describe('with a matching WHERE clause', describePatch({ + path: '/read-append.ttl', + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-append.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) }) - describe('on a resource with write-only access', () => { - before(() => backup('patch/write-only.ttl')) - after(() => restore('patch/write-only.ttl')) - - it('returns a 200', () => - request.patch('/write-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/write-only.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') - }) + describe('on a resource with read-write access', () => { + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:insert { ?a . }; + p:where { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:insert { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) }) }) - describe('inserting (= append with WHERE)', () => { - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { ?a . }; - p:where { ?a . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) - }) - }) - - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { ?a . }; - p:where { ?a . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with append-only access', () => { - before(() => backup('patch/append-only.ttl')) - after(() => restore('patch/append-only.ttl')) - - it('returns a 403', () => - request.patch('/append-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { ?a . }; - p:where { ?a . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with write-only access', () => { - before(() => backup('patch/write-only.ttl')) - after(() => restore('patch/write-only.ttl')) - + describe('with delete', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: // Allowing the delete would either return 200 or 409, - // thereby incorrectly giving the user (guess-based) read access; + // thereby inappropriately giving the user (guess-based) read access; // therefore, we need to return 403. - it('returns a 403', () => - request.patch('/write-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { ?a . }; - p:where { ?a . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') - }) - }) + status: 403, + text: 'Access denied' + })) - describe('on a resource with read-append access', () => { - describe('with a matching WHERE clause', () => { - before(() => backup('patch/read-append.ttl')) - after(() => restore('patch/read-append.ttl')) - - it('returns a 200', () => - request.patch('/read-append.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-append.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') - }) - }) - - describe('with a non-matching WHERE clause', () => { - before(() => backup('patch/read-append.ttl')) - after(() => restore('patch/read-append.ttl')) - - it('returns a 409', () => - request.patch('/read-append.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-append.ttl'), - ' .\n .\n') - }) - }) - }) + describe('on a resource with read-append access', describePatch({ + path: '/read-append.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) describe('on a resource with read-write access', () => { - describe('with a matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 200', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-write.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') - }) - }) - - describe('with a non-matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 409', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-write.ttl'), - ' .\n .\n') - }) - }) + describe('with a patch for existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a patch for non-existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:delete { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:delete { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:delete { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) }) }) - describe('deleting', () => { - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) - }) - }) - - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with append-only access', () => { - before(() => backup('patch/append-only.ttl')) - after(() => restore('patch/append-only.ttl')) - - it('returns a 403', () => - request.patch('/append-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with write-only access', () => { - before(() => backup('patch/write-only.ttl')) - after(() => restore('patch/write-only.ttl')) - + describe('deleting and inserting', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 403, + text: 'Access denied' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: // Allowing the delete would either return 200 or 409, - // thereby incorrectly giving the user (guess-based) read access; + // thereby inappropriately giving the user (guess-based) read access; // therefore, we need to return 403. - it('returns a 403', () => - request.patch('/write-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with read-append access', () => { - it('returns a 403', () => - request.patch('/read-append.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) + status: 403, + text: 'Access denied' + })) - it('does not modify the file', () => { - assert.equal(read('patch/read-append.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with read-write access', () => { - describe('with a patch for existing data', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 200', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; + describe('on a resource with read-append access', describePatch({ + path: '/read-append.ttl', + patch: `<> p:patches ; + p:insert { . }; p:delete { . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-write.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') - }) - }) - - describe('with a patch for non-existing data', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 409', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-write.ttl'), - ' .\n .\n') - }) - }) - - describe('with a matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 200', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:delete { ?a . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-write.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n') - }) - }) + }, { // expected: + status: 403, + text: 'Access denied' + })) - describe('with a non-matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 409', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:delete { ?a . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-write.ttl'), - ' .\n .\n') - }) - }) + describe('on a resource with read-write access', () => { + describe('executes deletes before inserts', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a patch for existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + })) + + describe('with a patch for non-existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:insert { . }; + p:delete { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:insert { ?a . }; + p:delete { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> p:patches ; + p:where { ?a . }; + p:insert { ?a . }; + p:delete { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) }) }) - describe('deleting and appending/inserting', () => { - describe('on a non-existing file', () => { - after(() => rm('patch/new.ttl')) - - it('returns a 409', () => - request.patch('/new.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not create the file', () => { - assert.isFalse(fs.existsSync('patch/new.ttl')) + // Creates a PATCH test for the given resource with the given expected outcomes + function describePatch ({ path, exists = true, patch, contentType = 'text/n3' }, + { status = 200, text, result }) { + return () => { + const filename = `patch${path}` + var originalContents + // Back up and restore an existing file + if (exists) { + before(() => backup(filename)) + after(() => restore(filename)) + // Store its contents to verify non-modification + if (!result) { + originalContents = read(filename) + } + // Ensure a non-existing file is removed + } else { + before(() => rm(filename)) + after(() => rm(filename)) + } + + // Create the request and obtain the response + var response + before((done) => { + request.patch(path) + .set('Authorization', `Bearer ${userCredentials}`) + .set('Content-Type', contentType) + .send(`@prefix p: .\n${patch}`) + .then(res => { response = res }) + .then(done, done) }) - }) - - describe('on a resource with read-only access', () => { - it('returns a 403', () => - request.patch('/read-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - it('does not modify the file', () => { - assert.equal(read('patch/read-only.ttl'), - ' .\n .\n') + // Verify the response's status code and body text + it(`returns HTTP status code ${status}`, () => { + assert.isObject(response) + assert.equal(response.statusCode, status) }) - }) - - describe('on a resource with append-only access', () => { - before(() => backup('patch/append-only.ttl')) - after(() => restore('patch/append-only.ttl')) - - it('returns a 403', () => - request.patch('/append-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') - }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') + it(`has "${text}" in the response`, () => { + assert.isObject(response) + assert.include(response.text, text) }) - }) - - describe('on a resource with write-only access', () => { - before(() => backup('patch/write-only.ttl')) - after(() => restore('patch/write-only.ttl')) - // Allowing the delete would either return 200 or 409, - // thereby incorrectly giving the user (guess-based) read access; - // therefore, we need to return 403. - it('returns a 403', () => - request.patch('/write-only.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') + // For existing files, verify correct patch application + if (exists) { + if (result) { + it('patches the file correctly', () => { + assert.equal(read(filename), result) }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/append-only.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with read-append access', () => { - it('returns a 403', () => - request.patch('/read-append.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(403) - .then(response => { - assert.include(response.text, 'Access denied') + } else { + it('does not modify the file', () => { + assert.equal(read(filename), originalContents) }) - ) - - it('does not modify the file', () => { - assert.equal(read('patch/read-append.ttl'), - ' .\n .\n') - }) - }) - - describe('on a resource with read-write access', () => { - it('executes deletes before inserts', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') + } + // For non-existing files, verify creation and contents + } else { + if (result) { + it('creates the file', () => { + assert.isTrue(fs.existsSync(`${root}/${path}`)) }) - ) - - describe('with a patch for existing data', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 200', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-write.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n') - }) - }) - - describe('with a patch for non-existing data', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 409', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:insert { . }; - p:delete { . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-write.ttl'), - ' .\n .\n') - }) - }) - - describe('with a matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 200', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }; - p:delete { ?a . }.` - )) - .expect(200) - .then(response => { - assert.include(response.text, 'Patch applied successfully') - }) - ) - - it('patches the file', () => { - assert.equal(read('patch/read-write.ttl'), - '@prefix : .\n@prefix tim: .\n\ntim:a tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n') - }) - }) - describe('with a non-matching WHERE clause', () => { - before(() => backup('patch/read-write.ttl')) - after(() => restore('patch/read-write.ttl')) - - it('returns a 409', () => - request.patch('/read-write.ttl') - .set('Authorization', `Bearer ${userCredentials}`) - .set('Content-Type', 'text/n3') - .send(n3Patch(` - <> p:patches ; - p:where { ?a . }; - p:insert { ?a . }; - p:delete { ?a . }.` - )) - .expect(409) - .then(response => { - assert.include(response.text, 'The patch could not be applied') - }) - ) - - it('does not change the file', () => { - assert.equal(read('patch/read-write.ttl'), - ' .\n .\n') - }) - }) - }) - }) + it('writes the correct contents', () => { + assert.equal(read(filename), result) + }) + } else { + it('does not create the file', () => { + assert.isFalse(fs.existsSync(`${root}/${path}`)) + }) + } + } + } + } }) - -function n3Patch (contents) { - return `@prefix p: .\n${contents}` -} From 180a11434cbb07657216df5c930b60defd518fca Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 12 Jul 2017 20:44:59 -0400 Subject: [PATCH 081/178] Support client certificates via X-SSL-Cert header. The WebID-TLS implementation assumed an end-to-end TLS connection from the client to the server, so reverse proxies were not possible. With this commit, the reverse proxy can terminate the TLS connection and pass the client certificate through the X-SSL-Cert HTTP header. --- .travis.yml | 14 ++- lib/api/authn/webid-tls.js | 49 ++++++++++- package.json | 3 +- test/integration/acl-tls.js | 165 ++++++++++++++++++++++++++++-------- 4 files changed, 190 insertions(+), 41 deletions(-) diff --git a/.travis.yml b/.travis.yml index 32e58f9eb..70eb05f5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,20 @@ sudo: false language: node_js node_js: - "6.0" +env: + - CXX=g++-4.8 -cache: - directories: - - node_modules addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 hosts: - nic.localhost - tim.localhost - nicola.localhost + +cache: + directories: + - node_modules diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index c63012407..128b08f04 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,5 +1,8 @@ var webid = require('webid/tls') var debug = require('../../debug').authentication +var x509 = require('x509') + +const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m function authenticate () { return handler @@ -13,10 +16,9 @@ function handler (req, res, next) { return next() } - var certificate = req.connection.getPeerCertificate() - // Certificate is empty? skip - if (certificate === null || Object.keys(certificate).length === 0) { - debug('No client certificate found in the request. Did the user click on a cert?') + // No certificate? skip + const certificate = getCertificateViaTLS(req) || getCertificateViaHeader(req) + if (!certificate) { setEmptySession(req) return next() } @@ -36,6 +38,45 @@ function handler (req, res, next) { }) } +// Tries to obtain a client certificate retrieved through the TLS handshake +function getCertificateViaTLS (req) { + const certificate = req.connection.getPeerCertificate() + if (certificate !== null && Object.keys(certificate).length > 0) { + return certificate + } + debug('No peer certificate received during TLS handshake.') +} + +// Tries to obtain a client certificate retrieved through the X-SSL-Cert header +function getCertificateViaHeader (req) { + // Try to retrieve the certificate from the header + const header = req.headers['x-ssl-cert'] + if (!header) { + return debug('No certificate received through the X-SSL-Cert header.') + } + // The certificate's newlines have been replaced by tabs + // in order to fit in an HTTP header (NGINX does this automatically) + const rawCertificate = header.replace(/\t/g, '\n') + + // Ensure the header contains a valid certificate + // (x509 unsafely interprets it as a file path otherwise) + if (!CERTIFICATE_MATCHER.test(rawCertificate)) { + return debug('Invalid value for the X-SSL-Cert header.') + } + + // Parse and convert the certificate to the format the webid library expects + try { + const { publicKey, extensions } = x509.parseCert(rawCertificate) + return { + modulus: publicKey.n, + exponent: '0x' + parseInt(publicKey.e, 10).toString(16), + subjectaltname: extensions && extensions.subjectAlternativeName + } + } catch (error) { + debug('Invalid certificate received through the X-SSL-Cert header.') + } +} + function setEmptySession (req) { req.session.userId = '' req.session.identified = false diff --git a/package.json b/package.json index 62ba3a954..89ab6a693 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "uuid": "^3.0.0", "valid-url": "^1.0.9", "vhost": "^3.0.2", - "webid": "^0.3.7" + "webid": "^0.3.7", + "x509": "^0.3.2" }, "devDependencies": { "chai": "^3.5.0", diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 4cf201707..d6bceeb81 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -18,12 +18,43 @@ var rm = require('../test-utils').rm var ldnode = require('../../index') var ns = require('solid-namespace')($rdf) -describe('ACL HTTP', function () { +var address = 'https://localhost:3456/test/' +let rootPath = path.join(__dirname, '../resources') + +var aclExtension = '.acl' +var metaExtension = '.meta' + +var testDir = 'acl-tls/testDir' +var testDirAclFile = testDir + '/' + aclExtension +var testDirMetaFile = testDir + '/' + metaExtension + +var abcFile = testDir + '/abc.ttl' +var abcAclFile = abcFile + aclExtension + +var globFile = testDir + '/*' + +var groupFile = testDir + '/group' + +var origin1 = 'http://example.org/' +var origin2 = 'http://example.com/' + +var user1 = 'https://user1.databox.me/profile/card#me' +var user2 = 'https://user2.databox.me/profile/card#me' +var userCredentials = { + user1: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) + }, + user2: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) + } +} + +describe('ACL with WebID+TLS', function () { this.timeout(10000) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - var address = 'https://localhost:3456/test/' - let rootPath = path.join(__dirname, '../resources') var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', @@ -45,36 +76,6 @@ describe('ACL HTTP', function () { fs.removeSync(path.join(rootPath, 'index.html.acl')) }) - var aclExtension = '.acl' - var metaExtension = '.meta' - - var testDir = 'acl-tls/testDir' - var testDirAclFile = testDir + '/' + aclExtension - var testDirMetaFile = testDir + '/' + metaExtension - - var abcFile = testDir + '/abc.ttl' - var abcAclFile = abcFile + aclExtension - - var globFile = testDir + '/*' - - var groupFile = testDir + '/group' - - var origin1 = 'http://example.org/' - var origin2 = 'http://example.com/' - - var user1 = 'https://user1.databox.me/profile/card#me' - var user2 = 'https://user2.databox.me/profile/card#me' - var userCredentials = { - user1: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - }, - user2: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - } - } - function createOptions (path, user) { var options = { url: address + path, @@ -971,3 +972,101 @@ describe('ACL HTTP', function () { }) }) }) + +describe('ACL with WebID through X-SSL-Cert', function () { + this.timeout(10000) + + var ldpHttpsServer + before(function (done) { + const ldp = ldnode.createServer({ + mount: '/test', + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + strictOrigin: true, + auth: 'tls' + }) + ldpHttpsServer = ldp.listen(3456, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'index.html')) + fs.removeSync(path.join(rootPath, 'index.html.acl')) + }) + + function prepareRequest (certHeader, setResponse) { + return done => { + const options = { + url: address + '/acl-tls/write-acl/.acl', + headers: { 'X-SSL-Cert': certHeader } + } + request(options, function (error, response) { + setResponse(response) + done(error) + }) + } + } + + describe('without certificate', function () { + var response + before(prepareRequest('', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with a valid certificate', function () { + // Escape certificate for usage in HTTP header + const escapedCert = userCredentials.user1.cert.toString() + .replace(/\n/g, '\t') + + var response + before(prepareRequest(escapedCert, res => { response = res })) + + it('should return 200', function () { + assert.propertyVal(response, 'statusCode', 200) + }) + + it('should set the User header', function () { + assert.propertyVal(response.headers, 'user', 'https://user1.databox.me/profile/card#me') + }) + }) + + describe('with a local filename as certificate', function () { + const certFile = path.join(__dirname, '../keys/user1-cert.pem') + + var response + before(prepareRequest(certFile, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate value', function () { + var response + before(prepareRequest('xyz', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate', function () { + const invalidCert = +`-----BEGIN CERTIFICATE----- +ABCDEF +-----END CERTIFICATE-----` + .replace(/\n/g, '\t') + + var response + before(prepareRequest(invalidCert, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) +}) From 97322a003592570d385d671f549cffb2f84602bf Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 12 Jul 2017 22:38:10 -0400 Subject: [PATCH 082/178] Make x509 dependency optional. This is a native module, which might not compile on all platforms. Furthermore, it is only needed for header-based WebID auth. --- lib/api/authn/webid-tls.js | 3 ++- package.json | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 128b08f04..9ebc65918 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,6 +1,6 @@ var webid = require('webid/tls') var debug = require('../../debug').authentication -var x509 = require('x509') +var x509 // optional dependency, load lazily const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m @@ -65,6 +65,7 @@ function getCertificateViaHeader (req) { } // Parse and convert the certificate to the format the webid library expects + if (!x509) x509 = require('x509') try { const { publicKey, extensions } = x509.parseCert(rawCertificate) return { diff --git a/package.json b/package.json index 89ab6a693..cbbcedfae 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,9 @@ "uuid": "^3.0.0", "valid-url": "^1.0.9", "vhost": "^3.0.2", - "webid": "^0.3.7", + "webid": "^0.3.7" + }, + "optionalDependencies": { "x509": "^0.3.2" }, "devDependencies": { From c541a0a4365ad84aa12162bf631cd903708899d4 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 09:58:04 -0400 Subject: [PATCH 083/178] Add acceptCertificateHeader option. --- bin/lib/options.js | 6 ++++++ lib/api/authn/webid-tls.js | 3 +++ lib/create-app.js | 1 + test/integration/acl-tls.js | 3 ++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bin/lib/options.js b/bin/lib/options.js index 1e882581c..96bd66732 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -75,6 +75,12 @@ module.exports = [ return answers.webid } }, + { + name: 'acceptCertificateHeader', + question: 'Accept client certificates through the X-SSL-Cert header (for reverse proxies)', + default: false, + prompt: false + }, { name: 'useOwner', question: 'Do you already have a WebID?', diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 9ebc65918..a690effa1 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -49,6 +49,9 @@ function getCertificateViaTLS (req) { // Tries to obtain a client certificate retrieved through the X-SSL-Cert header function getCertificateViaHeader (req) { + // Only allow the X-SSL-Cert header if explicitly enabled + if (!req.app.locals.acceptCertificateHeader) return + // Try to retrieve the certificate from the header const header = req.headers['x-ssl-cert'] if (!header) { diff --git a/lib/create-app.js b/lib/create-app.js index 50a06fdab..fea9d03a0 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -187,6 +187,7 @@ function initAuthentication (argv, app) { case 'tls': // Enforce authentication with WebID-TLS on all LDP routes app.use('/', API.tls.authenticate()) + app.locals.acceptCertificateHeader = argv.acceptCertificateHeader break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index d6bceeb81..a0e032a1c 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -985,7 +985,8 @@ describe('ACL with WebID through X-SSL-Cert', function () { sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, strictOrigin: true, - auth: 'tls' + auth: 'tls', + acceptCertificateHeader: true }) ldpHttpsServer = ldp.listen(3456, done) }) From b0591af001b4a7060cdc48cbe138ab37cb85b689 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 09:59:45 -0400 Subject: [PATCH 084/178] WebID through header doesn't require TLS. --- lib/api/authn/webid-tls.js | 5 +++-- lib/create-server.js | 10 +++++++--- test/integration/acl-tls.js | 5 +---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index a690effa1..a8719115a 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -40,8 +40,9 @@ function handler (req, res, next) { // Tries to obtain a client certificate retrieved through the TLS handshake function getCertificateViaTLS (req) { - const certificate = req.connection.getPeerCertificate() - if (certificate !== null && Object.keys(certificate).length > 0) { + const certificate = req.connection.getPeerCertificate && + req.connection.getPeerCertificate() + if (certificate && Object.keys(certificate).length > 0) { return certificate } debug('No peer certificate received during TLS handshake.') diff --git a/lib/create-server.js b/lib/create-server.js index 6e4c0b225..049b5f195 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -12,7 +12,7 @@ function createServer (argv, app) { argv = argv || {} app = app || express() var ldpApp = createApp(argv) - var ldp = ldpApp.locals.ldp + var ldp = ldpApp.locals.ldp || {} var mount = argv.mount || '/' // Removing ending '/' if (mount.length > 1 && @@ -21,9 +21,13 @@ function createServer (argv, app) { } app.use(mount, ldpApp) debug.settings('Base URL (--mount): ' + mount) - var server = http.createServer(app) - if (ldp && (ldp.webid || ldp.idp || argv.sslKey || argv.sslCert)) { + var server + var needsTLS = argv.sslKey || argv.sslCert || + (ldp.webid || ldp.idp) && !argv.acceptCertificateHeader + if (!needsTLS) { + server = http.createServer(app) + } else { debug.settings('SSL Private Key path: ' + argv.sslKey) debug.settings('SSL Certificate path: ' + argv.sslCert) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index a0e032a1c..2220c3a01 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -981,10 +981,7 @@ describe('ACL with WebID through X-SSL-Cert', function () { const ldp = ldnode.createServer({ mount: '/test', root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - strictOrigin: true, auth: 'tls', acceptCertificateHeader: true }) @@ -1000,7 +997,7 @@ describe('ACL with WebID through X-SSL-Cert', function () { function prepareRequest (certHeader, setResponse) { return done => { const options = { - url: address + '/acl-tls/write-acl/.acl', + url: address.replace('https', 'http') + '/acl-tls/write-acl/.acl', headers: { 'X-SSL-Cert': certHeader } } request(options, function (error, response) { From de6012cacdbddbf7e99e12ebc1cf59b9d80e00e9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 13:54:07 -0400 Subject: [PATCH 085/178] Make certificate header name customizable. --- bin/lib/options.js | 6 +++--- lib/api/authn/webid-tls.js | 15 ++++++++------- lib/create-app.js | 4 +++- lib/create-server.js | 2 +- test/integration/acl-tls.js | 2 +- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/bin/lib/options.js b/bin/lib/options.js index 96bd66732..3fd43fd92 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -76,9 +76,9 @@ module.exports = [ } }, { - name: 'acceptCertificateHeader', - question: 'Accept client certificates through the X-SSL-Cert header (for reverse proxies)', - default: false, + name: 'certificateHeader', + question: 'Accept client certificates through this HTTP header (for reverse proxies)', + default: '', prompt: false }, { diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index a8719115a..9f4e147c0 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -48,15 +48,16 @@ function getCertificateViaTLS (req) { debug('No peer certificate received during TLS handshake.') } -// Tries to obtain a client certificate retrieved through the X-SSL-Cert header +// Tries to obtain a client certificate retrieved through an HTTP header function getCertificateViaHeader (req) { - // Only allow the X-SSL-Cert header if explicitly enabled - if (!req.app.locals.acceptCertificateHeader) return + // Only allow header-based certificates if explicitly enabled + const headerName = req.app.locals.certificateHeader + if (!headerName) return // Try to retrieve the certificate from the header - const header = req.headers['x-ssl-cert'] + const header = req.headers[headerName] if (!header) { - return debug('No certificate received through the X-SSL-Cert header.') + return debug(`No certificate received through the ${headerName} header.`) } // The certificate's newlines have been replaced by tabs // in order to fit in an HTTP header (NGINX does this automatically) @@ -65,7 +66,7 @@ function getCertificateViaHeader (req) { // Ensure the header contains a valid certificate // (x509 unsafely interprets it as a file path otherwise) if (!CERTIFICATE_MATCHER.test(rawCertificate)) { - return debug('Invalid value for the X-SSL-Cert header.') + return debug(`Invalid value for the ${headerName} header.`) } // Parse and convert the certificate to the format the webid library expects @@ -78,7 +79,7 @@ function getCertificateViaHeader (req) { subjectaltname: extensions && extensions.subjectAlternativeName } } catch (error) { - debug('Invalid certificate received through the X-SSL-Cert header.') + debug(`Invalid certificate received through the ${headerName} header.`) } } diff --git a/lib/create-app.js b/lib/create-app.js index fea9d03a0..7438c9529 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -187,7 +187,9 @@ function initAuthentication (argv, app) { case 'tls': // Enforce authentication with WebID-TLS on all LDP routes app.use('/', API.tls.authenticate()) - app.locals.acceptCertificateHeader = argv.acceptCertificateHeader + if (argv.certificateHeader) { + app.locals.certificateHeader = argv.certificateHeader.toLowerCase() + } break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) diff --git a/lib/create-server.js b/lib/create-server.js index 049b5f195..585c9247f 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -24,7 +24,7 @@ function createServer (argv, app) { var server var needsTLS = argv.sslKey || argv.sslCert || - (ldp.webid || ldp.idp) && !argv.acceptCertificateHeader + (ldp.webid || ldp.idp) && !argv.certificateHeader if (!needsTLS) { server = http.createServer(app) } else { diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 2220c3a01..b26c7bef7 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -983,7 +983,7 @@ describe('ACL with WebID through X-SSL-Cert', function () { root: rootPath, webid: true, auth: 'tls', - acceptCertificateHeader: true + certificateHeader: 'X-SSL-Cert' }) ldpHttpsServer = ldp.listen(3456, done) }) From 60a14ef6339294cf0bf79698d82cee7ed3cae157 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 15:26:22 -0400 Subject: [PATCH 086/178] Add reverse proxy documentation link. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ed0831ea5..7a5909a62 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ $ solid --idp --port 8443 --cert /path/to/cert --key /path/to/key --root ./accou Your users will have a dedicated folder under `./accounts`. Also, your root domain's website will be in `./accounts/yourdomain.tld`. New users can create accounts on `/api/accounts/new` and create new certificates on `/api/accounts/cert`. An easy-to-use sign-up tool is found on `/api/accounts`. +### Running Solid behind a reverse proxy (such as NGINX) +See [Running Solid behind a reverse proxy](https://github.com/solid/node-solid-server/wiki/Running-Solid-behind-a-reverse-proxy). + ##### How can send emails to my users with my Gmail? > To use Gmail you may need to configure ["Allow Less Secure Apps"](https://www.google.com/settings/security/lesssecureapps) in your Gmail account unless you are using 2FA in which case you would have to create an [Application Specific](https://security.google.com/settings/security/apppasswords) password. You also may need to unlock your account with ["Allow access to your Google account"](https://accounts.google.com/DisplayUnlockCaptcha) to use SMTP. From 95701d94297a9542a01a339c6de842c72fa03484 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Tue, 25 Jul 2017 13:02:55 -0400 Subject: [PATCH 087/178] Only set User header with WebID-TLS. Closes #523. Breaking change, needs new semver-major. --- lib/api/authn/index.js | 14 +------------- lib/create-app.js | 2 -- test/integration/acl-oidc.js | 4 ++-- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index 9caf979ea..137af4e96 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -16,20 +16,8 @@ function overrideWith (forceUserId) { } } -/** - * Sets the `User:` response header if the user has been authenticated. - */ -function setUserHeader (req, res, next) { - let session = req.session - let webId = session.identified && session.userId - - res.set('User', webId || '') - next() -} - module.exports = { oidc: require('./webid-oidc'), tls: require('./webid-tls'), - overrideWith, - setUserHeader + overrideWith } diff --git a/lib/create-app.js b/lib/create-app.js index 7438c9529..cfb3b0f69 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -205,8 +205,6 @@ function initAuthentication (argv, app) { // Enforce authentication with WebID-OIDC on all LDP routes app.use('/', oidc.rs.authenticate()) - app.use('/', API.authn.setUserHeader) - break default: throw new TypeError('Unsupported authentication scheme') diff --git a/test/integration/acl-oidc.js b/test/integration/acl-oidc.js index 65154d693..63063ad57 100644 --- a/test/integration/acl-oidc.js +++ b/test/integration/acl-oidc.js @@ -79,11 +79,11 @@ describe('ACL HTTP', function () { done() }) }) - it('should have `User` set in the Response Header', function (done) { + it('should not have the `User` set in the Response Header', function (done) { var options = createOptions('/no-acl/', 'user1') request(options, function (error, response, body) { assert.equal(error, null) - assert.equal(response.statusCode, 403) + assert.notProperty(response.headers, 'user') done() }) }) From f8537113ff955fcdcbfd28f540b05c487bcb1f7b Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Tue, 25 Jul 2017 15:42:30 -0400 Subject: [PATCH 088/178] Reject cookies from third-party applications. Otherwise, when a user is logged in to their Solid server, any third-party application could perform authenticated requests without permission by including the credentials set by the Solid server. Closes #524. Breaking change, needs new semver-major. --- lib/create-app.js | 25 ++++- lib/models/solid-host.js | 19 ++++ test/integration/authentication-oidc.js | 110 ++++++++++++++++++-- test/resources/accounts-scenario/alice/.acl | 4 +- test/unit/solid-host.js | 35 ++++++- 5 files changed, 177 insertions(+), 16 deletions(-) diff --git a/lib/create-app.js b/lib/create-app.js index cfb3b0f69..f5779d11a 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -20,6 +20,7 @@ const OidcManager = require('./models/oidc-manager') const config = require('./server-config') const defaults = require('../config/defaults') const options = require('./handlers/options') +const debug = require('./debug').authentication const corsSettings = cors({ methods: [ @@ -143,9 +144,29 @@ function initViews (app, configPath) { function initWebId (argv, app, ldp) { config.ensureWelcomePage(argv) - // Use session cookies + // Store the user's session key in a cookie + // (for same-domain browsing by people only) const useSecureCookies = argv.webid // argv.webid forces https and secure cookies - app.use(session(sessionSettings(useSecureCookies, argv.host))) + const sessionHandler = session(sessionSettings(useSecureCookies, argv.host)) + app.use((req, res, next) => { + sessionHandler(req, res, () => { + // Reject cookies from third-party applications. + // Otherwise, when a user is logged in to their Solid server, + // any third-party application could perform authenticated requests + // without permission by including the credentials set by the Solid server. + const origin = req.headers.origin + const userId = req.session.userId + if (!argv.host.allowsSessionFor(userId, origin)) { + debug(`Rejecting session for ${userId} from ${origin}`) + // Destroy session data + delete req.session.userId + delete req.session.identified + // Ensure this modified session is not saved + req.session.save = (done) => done() + } + next() + }) + }) let accountManager = AccountManager.from({ authMethod: argv.auth, diff --git a/lib/models/solid-host.js b/lib/models/solid-host.js index 1a699604e..79304fcc6 100644 --- a/lib/models/solid-host.js +++ b/lib/models/solid-host.js @@ -61,6 +61,25 @@ class SolidHost { } return this.parsedUri.protocol + '//' + accountName + '.' + this.host } + /** + * Determines whether the given user is allowed to restore a session + * from the given origin. + * + * @param userId {?string} + * @param origin {?string} + * @return {boolean} + */ + allowsSessionFor (userId, origin) { + // Allow no user or an empty origin + if (!userId || !origin) return true + // Allow the server's main domain + if (origin === this.serverUri) return true + // Allow the user's subdomain + const userIdHost = userId.replace(/([^:/])\/.*/, '$1') + if (origin === userIdHost) return true + // Disallow everything else + return false + } /** * Returns the /authorize endpoint URL object (used in login requests, etc). diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc.js index 2cb86b36c..d3a6c2a6f 100644 --- a/test/integration/authentication-oidc.js +++ b/test/integration/authentication-oidc.js @@ -145,18 +145,106 @@ describe('Authentication API (OIDC)', () => { fs.removeSync(path.join(aliceDbPath, 'users/users')) }) - it('should login and be redirected to /authorize', (done) => { - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .send({ password: alicePassword }) - .expect(302) - .expect('set-cookie', /connect.sid/) - .end((err, res) => { - let loginUri = res.header.location - expect(loginUri.startsWith(aliceServerUri + '/authorize')) - done(err) + describe('after performing a correct login', () => { + let response, cookie + before(done => { + aliceUserStore.initCollections() + aliceUserStore.createUser(aliceAccount, alicePassword) + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .send({ password: alicePassword }) + .end((err, res) => { + response = res + cookie = response.headers['set-cookie'][0] + done(err) + }) + }) + + it('should redirect to /authorize', () => { + const loginUri = response.headers.location + expect(response).to.have.property('status', 302) + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + }) + + it('should set the cookie', () => { + expect(cookie).to.match(/connect.sid=/) + }) + + it('should set the cookie with HttpOnly', () => { + expect(cookie).to.match(/HttpOnly/) + }) + + it('should set the cookie with Secure', () => { + expect(cookie).to.match(/Secure/) + }) + + describe('and performing a subsequent request', () => { + describe('without that cookie', () => { + let response + before(done => { + alice.get('/') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with that cookie and a non-matching origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with that cookie but without origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => { + expect(response).to.have.property('status', 200) + }) + }) + + describe('with that cookie and a matching origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => { + expect(response).to.have.property('status', 200) + }) }) + }) }) it('should throw a 400 if no username is provided', (done) => { diff --git a/test/resources/accounts-scenario/alice/.acl b/test/resources/accounts-scenario/alice/.acl index 9362b71cf..7a1573678 100644 --- a/test/resources/accounts-scenario/alice/.acl +++ b/test/resources/accounts-scenario/alice/.acl @@ -1,5 +1,5 @@ <#Owner> a ; <./>; - ; - , , . \ No newline at end of file + ; + , , . diff --git a/test/unit/solid-host.js b/test/unit/solid-host.js index 078d46b1e..04cdad308 100644 --- a/test/unit/solid-host.js +++ b/test/unit/solid-host.js @@ -26,7 +26,7 @@ describe('SolidHost', () => { }) }) - describe('uriForAccount()', () => { + describe('accountUriFor()', () => { it('should compose an account uri for an account name', () => { let config = { serverUri: 'https://test.local' @@ -42,6 +42,39 @@ describe('SolidHost', () => { }) }) + describe('allowsSessionFor()', () => { + let host + before(() => { + host = SolidHost.from({ + serverUri: 'https://test.local' + }) + }) + + it('should allow an empty userId and origin', () => { + expect(host.allowsSessionFor('', '')).to.be.true + }) + + it('should allow a userId with empty origin', () => { + expect(host.allowsSessionFor('https://user.test.local/profile/card#me', '')).to.be.true + }) + + it('should allow a userId with the user subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.test.local/profile/card#me', 'https://user.test.local')).to.be.true + }) + + it('should disallow a userId with another subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.test.local/profile/card#me', 'https://other.test.local')).to.be.false + }) + + it('should allow a userId with the server domain as origin', () => { + expect(host.allowsSessionFor('https://user.test.local/profile/card#me', 'https://test.local')).to.be.true + }) + + it('should disallow a userId from a different domain', () => { + expect(host.allowsSessionFor('https://user.test.local/profile/card#me', 'https://other.remote')).to.be.false + }) + }) + describe('cookieDomain getter', () => { it('should return null for single-part domains (localhost)', () => { let host = SolidHost.from({ From fc3ab8ff8fe48ca14000257c63a1800924218f6e Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Thu, 27 Jul 2017 11:22:51 -0400 Subject: [PATCH 089/178] Fix missing foaf: prefix in prefs.ttl --- default-templates/new-account/settings/prefs.ttl | 1 + 1 file changed, 1 insertion(+) diff --git a/default-templates/new-account/settings/prefs.ttl b/default-templates/new-account/settings/prefs.ttl index 8b5e8d3bb..b13d3aee6 100644 --- a/default-templates/new-account/settings/prefs.ttl +++ b/default-templates/new-account/settings/prefs.ttl @@ -1,5 +1,6 @@ @prefix dct: . @prefix pim: . +@prefix foaf: . <> a pim:ConfigurationFile; From c81b51d0dd930c0ea46eefee6fd87ce40da71848 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin Date: Tue, 8 Aug 2017 16:16:35 -0400 Subject: [PATCH 090/178] Fix merge oddness --- lib/api/accounts/user-accounts.js | 1 - test/integration/account-creation-oidc.js | 23 ++++------------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/lib/api/accounts/user-accounts.js b/lib/api/accounts/user-accounts.js index 8cf846d10..e3f704e8f 100644 --- a/lib/api/accounts/user-accounts.js +++ b/lib/api/accounts/user-accounts.js @@ -3,7 +3,6 @@ const express = require('express') const bodyParser = require('body-parser').urlencoded({ extended: false }) const debug = require('../../debug').accounts -const path = require('path') const CreateAccountRequest = require('../../requests/create-account-request') const AddCertificateRequest = require('../../requests/add-cert-request') diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc.js index 0c1865f3f..f4a26b836 100644 --- a/test/integration/account-creation-oidc.js +++ b/test/integration/account-creation-oidc.js @@ -206,12 +206,12 @@ describe('Single User signup page', () => { const serverUri = 'https://localhost:7457' const port = 7457 var ldpHttpsServer - const rootDir = path.join(__dirname, '/resources/accounts/single-user/') + const rootDir = path.join(__dirname, '../resources/accounts/single-user/') const ldp = ldnode.createServer({ port, root: rootDir, - sslKey: path.join(__dirname, '/keys/key.pem'), - sslCert: path.join(__dirname, '/keys/cert.pem'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, idp: false, strictOrigin: true @@ -224,7 +224,7 @@ describe('Single User signup page', () => { after(function () { if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(rootDir)) + fs.removeSync(rootDir) }) it('should return a 401 unauthorized without accept text/html', done => { @@ -233,19 +233,4 @@ describe('Single User signup page', () => { .expect(401) .end(done) }) - - it('should redirect to signup with accept text/html', done => { - server.get('/') - .set('accept', 'text/html') - .expect(302) - .expect('location', '/signup.html') - .end(done) - }) - - it('it should serve the signup page', done => { - server.get('/signup.html') - .expect(200) - .expect(/Admin Signup<\/title>/) - .end(done) - }) }) From 2b4bbe45fb440e0f98dd5fafee81fccb8baf7229 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 8 Aug 2017 17:15:44 -0400 Subject: [PATCH 091/178] Add support for 'request' auth param --- default-views/auth/auth-hidden-fields.hbs | 1 + lib/requests/auth-request.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/default-views/auth/auth-hidden-fields.hbs b/default-views/auth/auth-hidden-fields.hbs index ddfe82507..35d9fd316 100644 --- a/default-views/auth/auth-hidden-fields.hbs +++ b/default-views/auth/auth-hidden-fields.hbs @@ -5,3 +5,4 @@ <input type="hidden" name="redirect_uri" id="redirect_uri" value="{{redirect_uri}}" /> <input type="hidden" name="state" id="state" value="{{state}}" /> <input type="hidden" name="nonce" id="nonce" value="{{nonce}}" /> +<input type="hidden" name="request" id="request" value="{{request}}" /> diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js index 767f177b3..04da34707 100644 --- a/lib/requests/auth-request.js +++ b/lib/requests/auth-request.js @@ -10,7 +10,7 @@ const debug = require('./../debug').authentication * @type {Array<string>} */ const AUTH_QUERY_PARAMS = ['response_type', 'display', 'scope', - 'client_id', 'redirect_uri', 'state', 'nonce'] + 'client_id', 'redirect_uri', 'state', 'nonce', 'request'] /** * Base authentication request (used for login and password reset workflows). From 775b46dde95675dbac6c46db588767c662736753 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 8 Aug 2017 17:17:19 -0400 Subject: [PATCH 092/178] Return scope='openid webid' in oidc WWW-Authenticate header response --- lib/api/authn/webid-oidc.js | 2 +- test/integration/errors-oidc.js | 8 ++++---- test/unit/auth-handlers.js | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 90ffc65c6..92812e693 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -80,7 +80,7 @@ function setAuthenticateHeader (req, res, err) { let errorParams = { realm: locals.host.serverUri, - scope: 'openid', + scope: 'openid webid', error: err.error, error_description: err.error_description, error_uri: err.error_uri diff --git a/test/integration/errors-oidc.js b/test/integration/errors-oidc.js index 2ee70de84..9856b6a14 100644 --- a/test/integration/errors-oidc.js +++ b/test/integration/errors-oidc.js @@ -41,7 +41,7 @@ describe('OIDC error handling', function () { it('should return 401 Unauthorized with www-auth header', () => { return server.get('/profile/') .set('Accept', 'text/html') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid"') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') .expect(401) }) @@ -59,7 +59,7 @@ describe('OIDC error handling', function () { it('should return 401 Unauthorized with www-auth header', () => { return server.get('/profile/') .set('Accept', 'text/plain') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid"') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') .expect(401) }) }) @@ -78,7 +78,7 @@ describe('OIDC error handling', function () { it('should return a 401 error', () => { return server.get('/profile/') .set('Authorization', 'Bearer abcd123') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid", error="invalid_token", error_description="Access token is not a JWT"') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is not a JWT"') .expect(401) }) }) @@ -89,7 +89,7 @@ describe('OIDC error handling', function () { it('should return a 401 error', () => { return server.get('/profile/') .set('Authorization', 'Bearer ' + expiredToken) - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid", error="invalid_token", error_description="Access token is expired."') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is expired."') .expect(401) }) }) diff --git a/test/unit/auth-handlers.js b/test/unit/auth-handlers.js index a55912fb2..84c640983 100644 --- a/test/unit/auth-handlers.js +++ b/test/unit/auth-handlers.js @@ -33,7 +33,7 @@ describe('OIDC Handler', () => { expect(res.set).to.be.calledWith( 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' + 'Bearer realm="https://example.com", scope="openid webid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' ) }) @@ -44,7 +44,7 @@ describe('OIDC Handler', () => { expect(res.set).to.be.calledWith( 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid"' + 'Bearer realm="https://example.com", scope="openid webid"' ) }) }) From da8df9bab63fbd09c262e066df7183c5e5ecaf18 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 16:20:54 -0400 Subject: [PATCH 093/178] Use http-proxy-middleware for CORS proxy. By delegating all proxy functionality to an existing library, we avoid the need for extra tests and checks on our side. --- lib/handlers/proxy.js | 121 +++++++++++++++++------------------------- package.json | 1 + 2 files changed, 49 insertions(+), 73 deletions(-) diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js index d7893840d..39a8236cb 100644 --- a/lib/handlers/proxy.js +++ b/lib/handlers/proxy.js @@ -1,85 +1,60 @@ -module.exports = addProxy +module.exports = addCorsProxyHandler +const proxy = require('http-proxy-middleware') const cors = require('cors') -const http = require('http') -const https = require('https') const debug = require('../debug') const url = require('url') const isIp = require('is-ip') const ipRange = require('ip-range-check') const validUrl = require('valid-url') -function addProxy (app, path) { - debug.settings('XSS/CORS Proxy listening at /' + path + '?uri={uri}') - app.get( - path, - cors({ - methods: ['GET'], - exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, Content-Length', - maxAge: 1728000, - origin: true - }), - (req, res) => { - if (!validUrl.isUri(req.query.uri)) { - return res - .status(406) - .send('The uri passed is not valid') - } - - debug.settings('proxy received: ' + req.originalUrl) - - const hostname = url.parse(req.query.uri).hostname - - if (isIp(hostname) && ( - ipRange(hostname, '10.0.0.0/8') || - ipRange(hostname, '172.16.0.0/12') || - ipRange(hostname, '192.168.0.0/16') - )) { - return res - .status(406) - .send('Cannot proxy this IP') - } - const uri = req.query.uri - if (!uri) { - return res - .status(400) - .send('Proxy has no uri param ') - } - - debug.settings('Proxy destination URI: ' + uri) - - const protocol = uri.split(':')[0] - let request - if (protocol === 'http') { - request = http.get - } else if (protocol === 'https') { - request = https.get - } else { - return res.send(400) - } - - // Set the headers and uri of the proxied request - const opts = url.parse(uri) - opts.headers = req.headers - // See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - delete opts.headers.connection - delete opts.headers.host +const CORS_SETTINGS = { + methods: 'GET', + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, Content-Length', + maxAge: 1728000, + origin: true +} +const PROXY_SETTINGS = { + target: 'dynamic', + logLevel: 'silent', + router: req => req.destination.target, + pathRewrite: (path, req) => req.destination.path +} +const LOCAL_IP_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] - const _req = request(opts, (_res) => { - res.status(_res.statusCode) - // Set the response with the same header of proxied response - Object.keys(_res.headers).forEach((header) => { - if (!res.get(header)) { - res.setHeader(header.trim(), _res.headers[header]) - } - }) - _res.pipe(res) - }) +// Adds a CORS proxy handler to the application on the given path +function addCorsProxyHandler (app, path) { + const corsHandler = cors(CORS_SETTINGS) + const proxyHandler = proxy(PROXY_SETTINGS) - _req.on('error', (e) => { - res.send(500, 'Cannot proxy') - }) + debug.settings(`CORS proxy listening at ${path}?uri={uri}`) + app.get(path, extractProxyConfig, corsHandler, proxyHandler) +} - _req.end() - }) +// Extracts proxy configuration parameters from the request +function extractProxyConfig (req, res, next) { + // Retrieve and validate the destination URL + const uri = req.query.uri + debug.settings(`Proxy request for ${uri}`) + if (!uri) { + return res.status(400) + .send('Proxy has no uri param ') + } + if (!validUrl.isUri(uri)) { + return res.status(406) + .send('The uri passed is not valid') + } + + // Ensure the host is not a local IP + // TODO: guard against hostnames such as 'localhost' as well + const { protocol, host, hostname, path } = url.parse(uri) + if (isIp(hostname) && LOCAL_IP_RANGES.some(r => ipRange(hostname, r))) { + return res + .status(406) + .send('Cannot proxy this IP') + } + + // Add the proxy configuration to the request + req.destination = { path, target: `${protocol}//${host}` } + next() } diff --git a/package.json b/package.json index 60ebeba43..7afa97634 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "fs-extra": "^2.1.0", "glob": "^7.1.1", "handlebars": "^4.0.6", + "http-proxy-middleware": "^0.17.4", "inquirer": "^1.0.2", "ip-range-check": "0.0.1", "is-ip": "^1.0.0", From 1696e35b2cb3fef9dbe55d2ed2d0f11dbda0429c Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 16:30:52 -0400 Subject: [PATCH 094/178] Correct proxy error codes. --- lib/handlers/proxy.js | 11 ++--------- test/integration/proxy.js | 26 ++++++++++++++++++++------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js index 39a8236cb..c8b32289f 100644 --- a/lib/handlers/proxy.js +++ b/lib/handlers/proxy.js @@ -36,22 +36,15 @@ function extractProxyConfig (req, res, next) { // Retrieve and validate the destination URL const uri = req.query.uri debug.settings(`Proxy request for ${uri}`) - if (!uri) { - return res.status(400) - .send('Proxy has no uri param ') - } if (!validUrl.isUri(uri)) { - return res.status(406) - .send('The uri passed is not valid') + return res.status(400).send(`Invalid URL passed: ${uri || '(none)'}`) } // Ensure the host is not a local IP // TODO: guard against hostnames such as 'localhost' as well const { protocol, host, hostname, path } = url.parse(uri) if (isIp(hostname) && LOCAL_IP_RANGES.some(r => ipRange(hostname, r))) { - return res - .status(406) - .send('Cannot proxy this IP') + return res.status(400).send(`Cannot proxy ${uri}`) } // Add the proxy configuration to the request diff --git a/test/integration/proxy.js b/test/integration/proxy.js index 82756b4c6..5bb5bb7ab 100644 --- a/test/integration/proxy.js +++ b/test/integration/proxy.js @@ -20,20 +20,34 @@ describe('proxy', () => { .expect(200, done) }) - it('should return error on local network requests', (done) => { + it('should return 400 when the uri parameter is missing', (done) => { + nock('https://192.168.0.0').get('/').reply(200) + server.get('/proxy') + .expect('Invalid URL passed: (none)') + .expect(400) + .end(done) + }) + + it('should return 400 on local network requests', (done) => { nock('https://192.168.0.0').get('/').reply(200) server.get('/proxy?uri=https://192.168.0.0/') - .expect(406, done) + .expect('Cannot proxy https://192.168.0.0/') + .expect(400) + .end(done) }) - it('should return error on invalid uri', (done) => { + it('should return 400 on invalid uri', (done) => { server.get('/proxy?uri=HELLOWORLD') - .expect(406, done) + .expect('Invalid URL passed: HELLOWORLD') + .expect(400) + .end(done) }) - it('should return error on relative paths', (done) => { + it('should return 400 on relative paths', (done) => { server.get('/proxy?uri=../') - .expect(406, done) + .expect('Invalid URL passed: ../') + .expect(400) + .end(done) }) it('should return the same headers of proxied request', (done) => { From 1afb00a92192a8979f752b14598fe69d95dd376b Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 16:48:03 -0400 Subject: [PATCH 095/178] Update IP packages. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7afa97634..1329adf27 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "handlebars": "^4.0.6", "http-proxy-middleware": "^0.17.4", "inquirer": "^1.0.2", - "ip-range-check": "0.0.1", - "is-ip": "^1.0.0", + "ip-range-check": "0.0.2", + "is-ip": "^2.0.0", "jsonld": "^0.4.5", "li": "^1.0.1", "mime-types": "^2.1.11", From e61de87f84b4854a9a66d93331cc62f88b6704ba Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 17:02:11 -0400 Subject: [PATCH 096/178] Ensure the host is not a local IP. --- lib/handlers/proxy.js | 29 ++++++++++++++++------ test/integration/proxy.js | 51 ++++++++++++++++++++++++--------------- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js index c8b32289f..5250af36b 100644 --- a/lib/handlers/proxy.js +++ b/lib/handlers/proxy.js @@ -4,6 +4,7 @@ const proxy = require('http-proxy-middleware') const cors = require('cors') const debug = require('../debug') const url = require('url') +const dns = require('dns') const isIp = require('is-ip') const ipRange = require('ip-range-check') const validUrl = require('valid-url') @@ -20,7 +21,12 @@ const PROXY_SETTINGS = { router: req => req.destination.target, pathRewrite: (path, req) => req.destination.path } -const LOCAL_IP_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] +const LOCAL_IP_RANGES = [ + '10.0.0.0/8', + '127.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16' +] // Adds a CORS proxy handler to the application on the given path function addCorsProxyHandler (app, path) { @@ -40,14 +46,21 @@ function extractProxyConfig (req, res, next) { return res.status(400).send(`Invalid URL passed: ${uri || '(none)'}`) } - // Ensure the host is not a local IP - // TODO: guard against hostnames such as 'localhost' as well + // Parse the URL and retrieve its host's IP address const { protocol, host, hostname, path } = url.parse(uri) - if (isIp(hostname) && LOCAL_IP_RANGES.some(r => ipRange(hostname, r))) { - return res.status(400).send(`Cannot proxy ${uri}`) + if (isIp(hostname)) { + addProxyConfig(null, hostname) + } else { + dns.lookup(hostname, addProxyConfig) } - // Add the proxy configuration to the request - req.destination = { path, target: `${protocol}//${host}` } - next() + // Verifies and adds the proxy configuration to the request + function addProxyConfig (error, hostAddress) { + // Ensure the host is not a local IP + if (error || LOCAL_IP_RANGES.some(r => ipRange(hostAddress, r))) { + return res.status(400).send(`Cannot proxy ${uri}`) + } + req.destination = { path, target: `${protocol}//${host}` } + next() + } } diff --git a/test/integration/proxy.js b/test/integration/proxy.js index 5bb5bb7ab..6441321cf 100644 --- a/test/integration/proxy.js +++ b/test/integration/proxy.js @@ -15,8 +15,8 @@ describe('proxy', () => { var server = supertest(ldp) it('should return the website in /proxy?uri', (done) => { - nock('https://amazingwebsite.tld').get('/').reply(200) - server.get('/proxy?uri=https://amazingwebsite.tld/') + nock('https://example.org').get('/').reply(200) + server.get('/proxy?uri=https://example.org/') .expect(200, done) }) @@ -28,10 +28,21 @@ describe('proxy', () => { .end(done) }) - it('should return 400 on local network requests', (done) => { - nock('https://192.168.0.0').get('/').reply(200) - server.get('/proxy?uri=https://192.168.0.0/') - .expect('Cannot proxy https://192.168.0.0/') + const LOCAL_IPS = ['127.0.0.0', '10.0.0.0', '172.16.0.0', '192.168.0.0'] + LOCAL_IPS.forEach(ip => { + it(`should return 400 for a ${ip} address`, (done) => { + nock(`https://${ip}`).get('/').reply(200) + server.get(`/proxy?uri=https://${ip}/`) + .expect(`Cannot proxy https://${ip}/`) + .expect(400) + .end(done) + }) + }) + + it('should return 400 with a local hostname', (done) => { + nock('https://nic.localhost').get('/').reply(200) + server.get('/proxy?uri=https://nic.localhost/') + .expect('Cannot proxy https://nic.localhost/') .expect(400) .end(done) }) @@ -51,7 +62,7 @@ describe('proxy', () => { }) it('should return the same headers of proxied request', (done) => { - nock('https://amazingwebsite.tld') + nock('https://example.org') .get('/') .reply(function (uri, req) { if (this.req.headers['accept'] !== 'text/turtle') { @@ -64,7 +75,7 @@ describe('proxy', () => { } }) - server.get('/proxy?uri=https://amazingwebsite.tld/') + server.get('/proxy?uri=https://example.org/') .set('test', 'test1') .set('accept', 'text/turtle') .expect(200) @@ -75,8 +86,8 @@ describe('proxy', () => { }) it('should also work on /proxy/ ?uri', (done) => { - nock('https://amazingwebsite.tld').get('/').reply(200) - server.get('/proxy/?uri=https://amazingwebsite.tld/') + nock('https://example.org').get('/').reply(200) + server.get('/proxy/?uri=https://example.org/') .expect((a) => { assert.equal(a.header['link'], null) }) @@ -87,31 +98,31 @@ describe('proxy', () => { async.parallel([ // 500 (next) => { - nock('https://amazingwebsite.tld').get('/404').reply(404) - server.get('/proxy/?uri=https://amazingwebsite.tld/404') + nock('https://example.org').get('/404').reply(404) + server.get('/proxy/?uri=https://example.org/404') .expect(404, next) }, (next) => { - nock('https://amazingwebsite.tld').get('/401').reply(401) - server.get('/proxy/?uri=https://amazingwebsite.tld/401') + nock('https://example.org').get('/401').reply(401) + server.get('/proxy/?uri=https://example.org/401') .expect(401, next) }, (next) => { - nock('https://amazingwebsite.tld').get('/500').reply(500) - server.get('/proxy/?uri=https://amazingwebsite.tld/500') + nock('https://example.org').get('/500').reply(500) + server.get('/proxy/?uri=https://example.org/500') .expect(500, next) }, (next) => { - nock('https://amazingwebsite.tld').get('/').reply(200) - server.get('/proxy/?uri=https://amazingwebsite.tld/') + nock('https://example.org').get('/').reply(200) + server.get('/proxy/?uri=https://example.org/') .expect(200, next) } ], done) }) it('should work with cors', (done) => { - nock('https://amazingwebsite.tld').get('/').reply(200) - server.get('/proxy/?uri=https://amazingwebsite.tld/') + nock('https://example.org').get('/').reply(200) + server.get('/proxy/?uri=https://example.org/') .set('Origin', 'http://example.com') .expect('Access-Control-Allow-Origin', 'http://example.com') .expect(200, done) From 883373ee7fa2f15b7592363ef51847824da71cf1 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 17:25:41 -0400 Subject: [PATCH 097/178] Pass the Host header. --- lib/handlers/proxy.js | 1 + test/integration/proxy.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/handlers/proxy.js b/lib/handlers/proxy.js index 5250af36b..488b4a392 100644 --- a/lib/handlers/proxy.js +++ b/lib/handlers/proxy.js @@ -18,6 +18,7 @@ const CORS_SETTINGS = { const PROXY_SETTINGS = { target: 'dynamic', logLevel: 'silent', + changeOrigin: true, router: req => req.destination.target, pathRewrite: (path, req) => req.destination.path } diff --git a/test/integration/proxy.js b/test/integration/proxy.js index 6441321cf..6542a74a6 100644 --- a/test/integration/proxy.js +++ b/test/integration/proxy.js @@ -20,6 +20,20 @@ describe('proxy', () => { .expect(200, done) }) + it('should pass the Host header to the proxied server', (done) => { + let headers + nock('https://example.org').get('/').reply(function (uri, body) { + headers = this.req.headers + return 200 + }) + server.get('/proxy?uri=https://example.org/') + .expect(200) + .end(error => { + assert.propertyVal(headers, 'host', 'example.org') + done(error) + }) + }) + it('should return 400 when the uri parameter is missing', (done) => { nock('https://192.168.0.0').get('/').reply(200) server.get('/proxy') From d7dfb162bac1c4cafdb3e3744bb2d3926bb4a9ae Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 30 Jun 2017 20:59:46 -0400 Subject: [PATCH 098/178] Expose ACL and user ID on request. The PATCH handler needs ACL for detailed permission checking, as the full set of needed permissions is unknown until the patch document has been parsed. --- lib/handlers/allow.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 5487d909a..dcc709065 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -27,9 +27,11 @@ function allow (mode) { suffix: ldp.suffixAcl, strictOrigin: ldp.strictOrigin }) + req.acl = acl getUserId(req, function (err, userId) { if (err) return next(err) + req.userId = userId var reqPath = res && res.locals && res.locals.path ? res.locals.path From 22b2f6c139eff47955e6b736e2d368e41963eb3b Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 6 Jul 2017 11:09:39 -0400 Subject: [PATCH 099/178] Test on Node 8. --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 70eb05f5e..86ae2b4cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,8 @@ sudo: false language: node_js node_js: - "6.0" + - "8.0" + - "node" env: - CXX=g++-4.8 From e785193e0984c891cb5636e7348ca8c2cbbbceab Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 6 Jul 2017 11:41:48 -0400 Subject: [PATCH 100/178] Allow additional HTTPS options. --- lib/create-server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/create-server.js b/lib/create-server.js index 585c9247f..2ec1fc2d5 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -57,10 +57,10 @@ function createServer (argv, app) { throw new Error('Can\'t find SSL cert in ' + argv.sslCert) } - var credentials = { + var credentials = Object.assign({ key: key, cert: cert - } + }, argv) if (ldp.webid && ldp.auth === 'tls') { credentials.requestCert = true From aa0b6b92127fe1bb34125ffa0eb4e9f71d00bda4 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 6 Jul 2017 12:01:39 -0400 Subject: [PATCH 101/178] Fix ACL TLS test. --- test/integration/acl-tls.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index b26c7bef7..03fb744f3 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -53,8 +53,6 @@ var userCredentials = { describe('ACL with WebID+TLS', function () { this.timeout(10000) - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', @@ -63,7 +61,8 @@ describe('ACL with WebID+TLS', function () { sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, strictOrigin: true, - auth: 'tls' + auth: 'tls', + rejectUnauthorized: false }) before(function (done) { From 5bb067271dbce8d37ba2bbfc4948a5f6ad18106a Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 6 Jul 2017 12:03:41 -0400 Subject: [PATCH 102/178] Set NODE_TLS_REJECT_UNAUTHORIZED for tests (only). --- lib/handlers/allow.js | 3 --- package.json | 2 +- test/integration/account-creation-oidc.js | 1 - test/integration/account-creation-tls.js | 1 - test/integration/errors-oidc.js | 2 -- test/integration/http-copy.js | 2 -- 6 files changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index dcc709065..b7befcb15 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -10,9 +10,6 @@ var async = require('async') var debug = require('../debug').ACL var utils = require('../utils') -// TODO should this be set? -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - function allow (mode) { return function allowHandler (req, res, next) { var ldp = req.app.locals.ldp diff --git a/package.json b/package.json index 1329adf27..e6f3cc1c5 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "scripts": { "solid": "node ./bin/solid.js", "standard": "standard", - "mocha": "nyc mocha ./test/**/*.js", + "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha ./test/**/*.js", "test": "npm run standard && npm run mocha", "test-integration": "mocha ./test/integration/*.js", "test-unit": "mocha ./test/unit/*.js", diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc.js index f4a26b836..39f9f21fa 100644 --- a/test/integration/account-creation-oidc.js +++ b/test/integration/account-creation-oidc.js @@ -8,7 +8,6 @@ const path = require('path') const fs = require('fs-extra') describe('AccountManager (OIDC account creation tests)', function () { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' this.timeout(10000) var serverUri = 'https://localhost:3457' diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls.js index 7b161b8dc..27376f58f 100644 --- a/test/integration/account-creation-tls.js +++ b/test/integration/account-creation-tls.js @@ -9,7 +9,6 @@ // // describe('AccountManager (TLS account creation tests)', function () { // this.timeout(10000) -// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' // // var address = 'https://localhost:3457' // var host = 'localhost:3457' diff --git a/test/integration/errors-oidc.js b/test/integration/errors-oidc.js index 9856b6a14..2abde6e34 100644 --- a/test/integration/errors-oidc.js +++ b/test/integration/errors-oidc.js @@ -5,8 +5,6 @@ const fs = require('fs-extra') const expect = require('chai').expect describe('OIDC error handling', function () { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - const serverUri = 'https://localhost:3457' var ldpHttpsServer const rootPath = path.join(__dirname, '../resources/accounts/errortests') diff --git a/test/integration/http-copy.js b/test/integration/http-copy.js index 7a2684c6e..20583800b 100644 --- a/test/integration/http-copy.js +++ b/test/integration/http-copy.js @@ -9,8 +9,6 @@ var solidServer = require('../../index') describe('HTTP COPY API', function () { this.timeout(10000) - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - var address = 'https://localhost:3456' var ldpHttpsServer From d9b77a8c5336c6a93ba0538d19ebca8c9e5775e0 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 18:00:33 -0400 Subject: [PATCH 103/178] Update nock. Version 9.0.14 brings Node 8 compatibility. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6f3cc1c5..d5b72aff5 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "dirty-chai": "^1.2.2", "hippie": "^0.5.0", "mocha": "^3.2.0", - "nock": "^9.0.2", + "nock": "^9.0.14", "node-mocks-http": "^1.5.6", "nyc": "^10.1.2", "proxyquire": "^1.7.10", From 01550b065aff80051a21fd2e4b811eff63606f10 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:00:54 -0400 Subject: [PATCH 104/178] Move OIDC-specific details out of createApp. --- lib/api/authn/webid-oidc.js | 19 +++++++++++++++++++ lib/create-app.js | 21 ++++----------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 92812e693..5e637cad1 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -5,6 +5,7 @@ const express = require('express') const bodyParser = require('body-parser').urlencoded({ extended: false }) +const OidcManager = require('../../models/oidc-manager') const { LoginRequest } = require('../../requests/login-request') @@ -17,6 +18,23 @@ const { SelectProviderRequest } = require('oidc-auth-manager').handlers +/** + * Sets up OIDC authentication for the given app. + * + * @param app {Object} Express.js app instance + * @param argv {Object} Config options hashmap + */ +function initialize (app, argv) { + const oidc = OidcManager.fromServerConfig(argv) + app.locals.oidc = oidc + oidc.initialize() + + // Attach the OIDC API + app.use('/', middleware(oidc)) + // Perform the actual authentication + app.use('/', oidc.rs.authenticate()) +} + /** * Returns a router with OIDC Relying Party and Identity Provider middleware: * @@ -137,6 +155,7 @@ function isEmptyToken (req) { } module.exports = { + initialize, isEmptyToken, middleware, setAuthenticateHeader, diff --git a/lib/create-app.js b/lib/create-app.js index f5779d11a..fde262570 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -16,7 +16,6 @@ const TokenService = require('./models/token-service') const capabilityDiscovery = require('./capability-discovery') const API = require('./api') const errorPages = require('./handlers/error-pages') -const OidcManager = require('./models/oidc-manager') const config = require('./server-config') const defaults = require('../config/defaults') const options = require('./handlers/options') @@ -183,7 +182,7 @@ function initWebId (argv, app, ldp) { app.use('/', API.accounts.middleware(accountManager)) // Set up authentication-related API endpoints and app.locals - initAuthentication(argv, app) + initAuthentication(app, argv) if (argv.idp) { app.use(vhost('*', LdpMiddleware(corsSettings))) @@ -193,10 +192,10 @@ function initWebId (argv, app, ldp) { /** * Sets up authentication-related routes and handlers for the app. * + * @param app {Object} Express.js app instance * @param argv {Object} Config options hashmap - * @param app {Function} Express.js app instance */ -function initAuthentication (argv, app) { +function initAuthentication (app, argv) { let authMethod = argv.auth if (argv.forceUser) { @@ -213,19 +212,7 @@ function initAuthentication (argv, app) { } break case 'oidc': - let oidc = OidcManager.fromServerConfig(argv) - app.locals.oidc = oidc - - oidc.initialize() - - // Initialize the WebId-OIDC authentication routes/api, including: - // user-facing Solid endpoints (/login, /logout, /api/auth/select-provider) - // and OIDC-specific ones - app.use('/', API.oidc.middleware(oidc)) - - // Enforce authentication with WebID-OIDC on all LDP routes - app.use('/', oidc.rs.authenticate()) - + API.oidc.initialize(app, argv) break default: throw new TypeError('Unsupported authentication scheme') From bde91b23b3a6519ee0bf611bce92a055c951d216 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:04:17 -0400 Subject: [PATCH 105/178] Move TLS-specific details out of createApp. --- lib/api/authn/webid-tls.js | 9 ++++++--- lib/create-app.js | 6 +----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 9f4e147c0..9229d74f6 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -4,8 +4,11 @@ var x509 // optional dependency, load lazily const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m -function authenticate () { - return handler +function initialize (app, argv) { + app.use('/', handler) + if (argv.certificateHeader) { + app.locals.certificateHeader = argv.certificateHeader.toLowerCase() + } } function handler (req, res, next) { @@ -102,7 +105,7 @@ function setAuthenticateHeader (req, res) { } module.exports = { - authenticate, + initialize, handler, setAuthenticateHeader, setEmptySession diff --git a/lib/create-app.js b/lib/create-app.js index fde262570..110da6579 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -205,11 +205,7 @@ function initAuthentication (app, argv) { switch (authMethod) { case 'tls': - // Enforce authentication with WebID-TLS on all LDP routes - app.use('/', API.tls.authenticate()) - if (argv.certificateHeader) { - app.locals.certificateHeader = argv.certificateHeader.toLowerCase() - } + API.tls.initialize(app, argv) break case 'oidc': API.oidc.initialize(app, argv) From 2e435ffe31594de675553fef3abb9a04c54e6e34 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:15:10 -0400 Subject: [PATCH 106/178] Make forceUser a separate module. --- lib/api/authn/force-user.js | 20 ++++++++++++++++++++ lib/api/authn/index.js | 20 +------------------- lib/create-app.js | 2 +- 3 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 lib/api/authn/force-user.js diff --git a/lib/api/authn/force-user.js b/lib/api/authn/force-user.js new file mode 100644 index 000000000..09600f04f --- /dev/null +++ b/lib/api/authn/force-user.js @@ -0,0 +1,20 @@ +const debug = require('../../debug').authentication + +/** + * Enforces the `--force-user` server flag, hardcoding a webid for all requests, + * for testing purposes. + */ +function initialize (app, argv) { + const forceUserId = argv.forceUser + app.use('/', (req, res, next) => { + debug(`Identified user (override): ${forceUserId}`) + req.session.userId = forceUserId + req.session.identified = true + res.set('User', forceUserId) + next() + }) +} + +module.exports = { + initialize +} diff --git a/lib/api/authn/index.js b/lib/api/authn/index.js index 137af4e96..db81d0ab8 100644 --- a/lib/api/authn/index.js +++ b/lib/api/authn/index.js @@ -1,23 +1,5 @@ -'use strict' - -const debug = require('../../debug').authentication - -/** - * Enforces the `--force-user` server flag, hardcoding a webid for all requests, - * for testing purposes. - */ -function overrideWith (forceUserId) { - return (req, res, next) => { - req.session.userId = forceUserId - req.session.identified = true - debug('Identified user (override): ' + forceUserId) - res.set('User', forceUserId) - return next() - } -} - module.exports = { oidc: require('./webid-oidc'), tls: require('./webid-tls'), - overrideWith + forceUser: require('./force-user.js') } diff --git a/lib/create-app.js b/lib/create-app.js index 110da6579..c7772161b 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -199,7 +199,7 @@ function initAuthentication (app, argv) { let authMethod = argv.auth if (argv.forceUser) { - app.use('/', API.authn.overrideWith(argv.forceUser)) + API.authn.forceUser.initialize(app, argv) return } From 1f4fd48d11bb2a0577a75630986b0b6f139734bf Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:18:50 -0400 Subject: [PATCH 107/178] Make create-app auth-agnostic. --- lib/api/index.js | 2 -- lib/create-app.js | 20 ++++---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/lib/api/index.js b/lib/api/index.js index 5ce7a3514..5c0cd0477 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -2,7 +2,5 @@ module.exports = { authn: require('./authn'), - oidc: require('./authn/webid-oidc'), - tls: require('./authn/webid-tls'), accounts: require('./accounts/user-accounts') } diff --git a/lib/create-app.js b/lib/create-app.js index c7772161b..f2c4405fe 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -196,23 +196,11 @@ function initWebId (argv, app, ldp) { * @param argv {Object} Config options hashmap */ function initAuthentication (app, argv) { - let authMethod = argv.auth - - if (argv.forceUser) { - API.authn.forceUser.initialize(app, argv) - return - } - - switch (authMethod) { - case 'tls': - API.tls.initialize(app, argv) - break - case 'oidc': - API.oidc.initialize(app, argv) - break - default: - throw new TypeError('Unsupported authentication scheme') + const auth = argv.forceUser ? 'forceUser' : argv.auth + if (!(auth in API.authn)) { + throw new Error(`Unsupported authentication scheme: ${auth}`) } + API.authn[auth].initialize(app, argv) } /** From 47697cadeaf5497818333a1f99a68da4988663f7 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:22:26 -0400 Subject: [PATCH 108/178] Remove redundant "identified" setting. --- lib/api/authn/force-user.js | 1 - lib/api/authn/webid-tls.js | 4 +--- lib/create-app.js | 1 - lib/requests/auth-request.js | 1 - package.json | 2 +- test/unit/auth-request.js | 1 - 6 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/api/authn/force-user.js b/lib/api/authn/force-user.js index 09600f04f..07b01be46 100644 --- a/lib/api/authn/force-user.js +++ b/lib/api/authn/force-user.js @@ -9,7 +9,6 @@ function initialize (app, argv) { app.use('/', (req, res, next) => { debug(`Identified user (override): ${forceUserId}`) req.session.userId = forceUserId - req.session.identified = true res.set('User', forceUserId) next() }) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 9229d74f6..cbe886d98 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -13,7 +13,7 @@ function initialize (app, argv) { function handler (req, res, next) { // User already logged in? skip - if (req.session.userId && req.session.identified) { + if (req.session.userId) { debug('User: ' + req.session.userId) res.set('User', req.session.userId) return next() @@ -34,7 +34,6 @@ function handler (req, res, next) { return next() } req.session.userId = result - req.session.identified = true debug('Identified user: ' + req.session.userId) res.set('User', req.session.userId) return next() @@ -88,7 +87,6 @@ function getCertificateViaHeader (req) { function setEmptySession (req) { req.session.userId = '' - req.session.identified = false } /** diff --git a/lib/create-app.js b/lib/create-app.js index f2c4405fe..80bac48ba 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -159,7 +159,6 @@ function initWebId (argv, app, ldp) { debug(`Rejecting session for ${userId} from ${origin}`) // Destroy session data delete req.session.userId - delete req.session.identified // Ensure this modified session is not saved req.session.save = (done) => done() } diff --git a/lib/requests/auth-request.js b/lib/requests/auth-request.js index 04da34707..cb8a532de 100644 --- a/lib/requests/auth-request.js +++ b/lib/requests/auth-request.js @@ -155,7 +155,6 @@ class AuthRequest { debug('Initializing user session with webId: ', userAccount.webId) session.userId = userAccount.webId - session.identified = true session.subject = { _id: userAccount.webId } diff --git a/package.json b/package.json index d5b72aff5..6963a4207 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.7.2", + "oidc-auth-manager": "^0.7.3", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", diff --git a/test/unit/auth-request.js b/test/unit/auth-request.js index a434d83bb..d4ae46da5 100644 --- a/test/unit/auth-request.js +++ b/test/unit/auth-request.js @@ -92,7 +92,6 @@ describe('AuthRequest', () => { request.initUserSession(alice) expect(request.session.userId).to.equal(webId) - expect(request.session.identified).to.be.true() let subject = request.session.subject expect(subject['_id']).to.equal(webId) }) From e9b55091f36e569921ac6a7650c3c983c9cd7798 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:28:23 -0400 Subject: [PATCH 109/178] Expose request.userId in OIDC handler. --- lib/api/authn/webid-oidc.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 5e637cad1..e046fc949 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -33,6 +33,14 @@ function initialize (app, argv) { app.use('/', middleware(oidc)) // Perform the actual authentication app.use('/', oidc.rs.authenticate()) + // Expose session.userId + app.use('/', (req, res, next) => { + const userId = oidc.webIdFromClaims(req.claims) + if (userId) { + req.session.userId = userId + } + next() + }) } /** From c9467b0412eed17d43225d16e55e9603bdceee8a Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 11:34:01 -0400 Subject: [PATCH 110/178] Remove userId from allow handler. Closes #517. --- lib/handlers/allow.js | 117 ++++++--------------------------------- lib/handlers/get.js | 4 +- lib/ldp-middleware.js | 14 ++--- test/unit/acl-checker.js | 42 +------------- 4 files changed, 26 insertions(+), 151 deletions(-) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index b7befcb15..6046453a8 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -1,7 +1,4 @@ -module.exports = { - allow, - userIdFromRequest -} +module.exports = allow var ACL = require('../acl-checker') var $rdf = require('rdflib') @@ -26,26 +23,21 @@ function allow (mode) { }) req.acl = acl - getUserId(req, function (err, userId) { - if (err) return next(err) - req.userId = userId - - var reqPath = res && res.locals && res.locals.path - ? res.locals.path - : req.path - ldp.exists(req.hostname, reqPath, (err, ret) => { - if (ret) { - var stat = ret.stream - } - if (!reqPath.endsWith('/') && !err && stat.isDirectory()) { - reqPath += '/' - } - var options = { - origin: req.get('origin'), - host: req.protocol + '://' + req.get('host') - } - return acl.can(userId, mode, baseUri + reqPath, next, options) - }) + var reqPath = res && res.locals && res.locals.path + ? res.locals.path + : req.path + ldp.exists(req.hostname, reqPath, (err, ret) => { + if (ret) { + var stat = ret.stream + } + if (!reqPath.endsWith('/') && !err && stat.isDirectory()) { + reqPath += '/' + } + var options = { + origin: req.get('origin'), + host: req.protocol + '://' + req.get('host') + } + return acl.can(req.session.userId, mode, baseUri + reqPath, next, options) }) } } @@ -94,80 +86,3 @@ function fetchDocument (host, ldp, baseUri) { ], callback) } } - -/** - * Extracts the Web ID from the request object (for purposes of access control). - * - * @param req {IncomingRequest} - * - * @return {string|null} Web ID - */ -function userIdFromRequest (req) { - let userId - let locals = req.app.locals - - if (req.session.userId) { - userId = req.session.userId - } else if (locals.authMethod === 'oidc') { - userId = locals.oidc.webIdFromClaims(req.claims) - } - - return userId -} - -function getUserId (req, callback) { - let userId = userIdFromRequest(req) - - callback(null, userId) - // var onBehalfOf = req.get('On-Behalf-Of') - // if (!onBehalfOf) { - // return callback(null, req.session.userId) - // } - // - // var delegator = utils.debrack(onBehalfOf) - // verifyDelegator(req.hostname, delegator, req.session.userId, - // function (err, res) { - // if (err) { - // err.status = 500 - // return callback(err) - // } - // - // if (res) { - // debug('Request User ID (delegation) :' + delegator) - // return callback(null, delegator) - // } - // return callback(null, req.session.userId) - // }) -} - -// function verifyDelegator (host, ldp, baseUri, delegator, delegatee, callback) { -// fetchDocument(host, ldp, baseUri)(delegator, function (err, delegatorGraph) { -// // TODO handle error -// if (err) { -// err.status = 500 -// return callback(err) -// } -// -// var delegatesStatements = delegatorGraph -// .each(delegatorGraph.sym(delegator), -// delegatorGraph.sym('http://www.w3.org/ns/auth/acl#delegates'), -// undefined) -// -// for (var delegateeIndex in delegatesStatements) { -// var delegateeValue = delegatesStatements[delegateeIndex] -// if (utils.debrack(delegateeValue.toString()) === delegatee) { -// callback(null, true) -// } -// } -// // TODO check if this should be false -// return callback(null, false) -// }) -// } -/** - * Callback used by verifyDelegator. - * @callback ACL~verifyDelegator_cb - * @param {Object} err Error occurred when reading the acl file - * @param {Number} err.status Status code of the error (HTTP way) - * @param {String} err.message Reason of the error - * @param {Boolean} result verification has passed or not - */ diff --git a/lib/handlers/get.js b/lib/handlers/get.js index 05cc60bb5..791670222 100644 --- a/lib/handlers/get.js +++ b/lib/handlers/get.js @@ -11,7 +11,7 @@ const url = require('url') var debug = require('debug')('solid:get') var debugGlob = require('debug')('solid:glob') -var acl = require('./allow') +var allow = require('./allow') var utils = require('../utils.js') var translate = require('../utils.js').translate @@ -200,7 +200,7 @@ function aclAllow (match, req, res, callback) { var root = ldp.idp ? ldp.root + req.hostname + '/' : ldp.root var relativePath = '/' + _path.relative(root, match) res.locals.path = relativePath - acl.allow('Read', req, res, function (err) { + allow('Read', req, res, function (err) { callback(err) }) } diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js index dfcbf3e1b..4e8ae0dbe 100644 --- a/lib/ldp-middleware.js +++ b/lib/ldp-middleware.js @@ -2,7 +2,7 @@ module.exports = LdpMiddleware var express = require('express') var header = require('./header') -var acl = require('./handlers/allow') +var allow = require('./handlers/allow') var get = require('./handlers/get') var post = require('./handlers/post') var put = require('./handlers/put') @@ -21,12 +21,12 @@ function LdpMiddleware (corsSettings) { router.use(corsSettings) } - router.copy('/*', acl.allow('Write'), copy) - router.get('/*', index, acl.allow('Read'), get) - router.post('/*', acl.allow('Append'), post) - router.patch('/*', acl.allow('Write'), patch) - router.put('/*', acl.allow('Write'), put) - router.delete('/*', acl.allow('Write'), del) + router.copy('/*', allow('Write'), copy) + router.get('/*', index, allow('Read'), get) + router.post('/*', allow('Append'), post) + router.patch('/*', allow('Write'), patch) + router.put('/*', allow('Write'), put) + router.delete('/*', allow('Write'), del) return router } diff --git a/test/unit/acl-checker.js b/test/unit/acl-checker.js index c8711f87e..a4569e5b8 100644 --- a/test/unit/acl-checker.js +++ b/test/unit/acl-checker.js @@ -1,15 +1,13 @@ 'use strict' const proxyquire = require('proxyquire') const chai = require('chai') -const { assert, expect } = chai +const { assert } = chai const dirtyChai = require('dirty-chai') chai.use(dirtyChai) -const sinon = require('sinon') const sinonChai = require('sinon-chai') chai.use(sinonChai) chai.should() const debug = require('../../lib/debug').ACL -const { userIdFromRequest } = require('../../lib/handlers/allow') class PermissionSetAlwaysGrant { checkAccess () { @@ -27,44 +25,6 @@ class PermissionSetAlwaysError { } } -describe('Allow handler', () => { - let req - let aliceWebId = 'https://alice.example.com/#me' - - beforeEach(() => { - req = { app: { locals: {} }, session: {} } - }) - - describe('userIdFromRequest()', () => { - it('should first look in session.userId', () => { - req.session.userId = aliceWebId - - let userId = userIdFromRequest(req) - - expect(userId).to.equal(aliceWebId) - }) - - it('should use webIdFromClaims() if applicable', () => { - req.app.locals.authMethod = 'oidc' - req.claims = {} - - let webIdFromClaims = sinon.stub().returns(aliceWebId) - req.app.locals.oidc = { webIdFromClaims } - - let userId = userIdFromRequest(req) - - expect(userId).to.equal(aliceWebId) - expect(webIdFromClaims).to.have.been.calledWith(req.claims) - }) - - it('should return falsy if all else fails', () => { - let userId = userIdFromRequest(req) - - expect(userId).to.not.be.ok() - }) - }) -}) - describe('ACLChecker unit test', () => { it('should callback with null on grant success', done => { let ACLChecker = proxyquire('../../lib/acl-checker', { From 42011d6c88b6b306871b11f070bd317402eadc39 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 12:50:42 -0400 Subject: [PATCH 111/178] Only set User header for TLS. --- lib/api/authn/force-user.js | 4 ++- test/integration/params.js | 8 +++-- test/unit/force-user.js | 70 +++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 test/unit/force-user.js diff --git a/lib/api/authn/force-user.js b/lib/api/authn/force-user.js index 07b01be46..f1d7b41e7 100644 --- a/lib/api/authn/force-user.js +++ b/lib/api/authn/force-user.js @@ -9,7 +9,9 @@ function initialize (app, argv) { app.use('/', (req, res, next) => { debug(`Identified user (override): ${forceUserId}`) req.session.userId = forceUserId - res.set('User', forceUserId) + if (argv.auth === 'tls') { + res.set('User', forceUserId) + } next() }) } diff --git a/test/integration/params.js b/test/integration/params.js index aeedd8cb3..00c7f3e4d 100644 --- a/test/integration/params.js +++ b/test/integration/params.js @@ -97,7 +97,7 @@ describe('LDNODE params', function () { }) }) - describe('forcedUser', function () { + describe('forceUser', function () { var ldpHttpsServer const port = 7777 @@ -107,6 +107,7 @@ describe('LDNODE params', function () { const configPath = path.join(rootPath, 'config') var ldp = ldnode.createServer({ + auth: 'tls', forceUser: 'https://fakeaccount.com/profile#me', dbPath, configPath, @@ -116,7 +117,8 @@ describe('LDNODE params', function () { sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - host: 'localhost:3457' + host: 'localhost:3457', + rejectUnauthorized: false }) before(function (done) { @@ -131,7 +133,7 @@ describe('LDNODE params', function () { var server = supertest(serverUri) - it('should find resource in correct path', function (done) { + it('sets the User header', function (done) { server.get('/hello.html') .expect('User', 'https://fakeaccount.com/profile#me') .end(done) diff --git a/test/unit/force-user.js b/test/unit/force-user.js new file mode 100644 index 000000000..0ed7d8d7c --- /dev/null +++ b/test/unit/force-user.js @@ -0,0 +1,70 @@ +const forceUser = require('../../lib/api/authn/force-user') +const sinon = require('sinon') +const chai = require('chai') +const { expect } = chai +const sinonChai = require('sinon-chai') +chai.use(sinonChai) + +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Force User', () => { + describe('a forceUser handler', () => { + let app, handler + before(() => { + app = { use: sinon.stub() } + const argv = { forceUser: USER } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + it('adds a route on /', () => { + expect(app.use).to.have.callCount(1) + expect(app.use).to.have.been.calledWith('/') + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('does not set the User header', () => { + expect(response.set).to.have.callCount(0) + }) + }) + }) + + describe('a forceUser handler for TLS', () => { + let handler + before(() => { + const app = { use: sinon.stub() } + const argv = { forceUser: USER, auth: 'tls' } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('sets the User header', () => { + expect(response.set).to.have.callCount(1) + expect(response.set).to.have.been.calledWith('User', USER) + }) + }) + }) +}) From c2463bc206d113cebc415e786e99484de0997d76 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Tue, 8 Aug 2017 17:49:17 -0400 Subject: [PATCH 112/178] Rename proxy to corsProxy. Making room for the authProxy option. --- README.md | 6 +++--- bin/lib/options.js | 11 ++++++++--- lib/create-app.js | 9 +++++++-- lib/handlers/{proxy.js => cors-proxy.js} | 0 lib/ldp.js | 4 ++-- test/integration/{proxy.js => cors-proxy.js} | 4 ++-- 6 files changed, 22 insertions(+), 12 deletions(-) rename lib/handlers/{proxy.js => cors-proxy.js} (100%) rename test/integration/{proxy.js => cors-proxy.js} (98%) diff --git a/README.md b/README.md index 7a5909a62..f65c3cb29 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - [x] [WebID+TLS Authentication](https://www.w3.org/2005/Incubator/webid/spec/tls/) - [x] [Real-time live updates](https://github.com/solid/solid-spec#subscribing) (using WebSockets) - [x] Identity provider for WebID -- [x] Proxy for cross-site data access +- [x] CORS proxy for cross-site data access - [ ] Group members in ACL - [x] Email account recovery @@ -154,7 +154,7 @@ $ solid start --help --ssl-key [value] Path to the SSL private key in PEM format --ssl-cert [value] Path to the SSL certificate key in PEM format --idp Enable multi-user mode (users can sign up for accounts) - --proxy [value] Serve proxy on path (default: '/proxy') + --corsProxy [value] Serve the CORS proxy on this path --file-browser [value] Url to file browser app (uses Warp by default) --data-browser Enable viewing RDF resources using a default data browser application (e.g. mashlib) --suffix-acl [value] Suffix for acl files (default: '.acl') @@ -198,7 +198,7 @@ default settings. mount: '/', // Where to mount Linked Data Platform webid: false, // Enable WebID+TLS authentication suffixAcl: '.acl', // Suffix for acl files - proxy: false, // Where to mount the proxy + corsProxy: false, // Where to mount the CORS proxy errorHandler: false, // function(err, req, res, next) to have a custom error handler errorPages: false // specify a path where the error pages are } diff --git a/bin/lib/options.js b/bin/lib/options.js index 3fd43fd92..5d789cb82 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -135,7 +135,7 @@ module.exports = [ // help: 'URI to use as a default app for resources (default: https://linkeddata.github.io/warp/#/list/)' // }, { - name: 'useProxy', + name: 'useCorsProxy', help: 'Do you want to have a CORS proxy endpoint?', flag: true, prompt: true, @@ -143,9 +143,14 @@ module.exports = [ }, { name: 'proxy', - help: 'Serve proxy on path', + help: 'Obsolete; use --corsProxy', + prompt: false + }, + { + name: 'corsProxy', + help: 'Serve the CORS proxy on this path', when: function (answers) { - return answers.useProxy + return answers.useCorsProxy }, default: '/proxy', prompt: true diff --git a/lib/create-app.js b/lib/create-app.js index 80bac48ba..089b56f0c 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -7,7 +7,7 @@ const uuid = require('uuid') const cors = require('cors') const LDP = require('./ldp') const LdpMiddleware = require('./ldp-middleware') -const proxy = require('./handlers/proxy') +const corsProxy = require('./handlers/cors-proxy') const SolidHost = require('./models/solid-host') const AccountManager = require('./models/account-manager') const vhost = require('vhost') @@ -54,7 +54,12 @@ function createApp (argv = {}) { // Adding proxy if (argv.proxy) { - proxy(app, argv.proxy) + console.error('The proxy configuration option has been renamed to corsProxy.') + argv.corsProxy = argv.proxy + delete argv.proxy + } + if (argv.corsProxy) { + corsProxy(app, argv.corsProxy) } // Options handler diff --git a/lib/handlers/proxy.js b/lib/handlers/cors-proxy.js similarity index 100% rename from lib/handlers/proxy.js rename to lib/handlers/cors-proxy.js diff --git a/lib/ldp.js b/lib/ldp.js index 8b802dd3d..58d18e527 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -75,8 +75,8 @@ class LDP { this.skin = true } - if (this.proxy && this.proxy[ 0 ] !== '/') { - this.proxy = '/' + this.proxy + if (this.corsProxy && this.corsProxy[ 0 ] !== '/') { + this.corsProxy = '/' + this.corsProxy } debug.settings('Server URI: ' + this.serverUri) diff --git a/test/integration/proxy.js b/test/integration/cors-proxy.js similarity index 98% rename from test/integration/proxy.js rename to test/integration/cors-proxy.js index 6542a74a6..69a5fa8c4 100644 --- a/test/integration/proxy.js +++ b/test/integration/cors-proxy.js @@ -6,10 +6,10 @@ var async = require('async') var ldnode = require('../../index') -describe('proxy', () => { +describe('CORS Proxy', () => { var ldp = ldnode({ root: path.join(__dirname, '../resources'), - proxy: '/proxy', + corsProxy: '/proxy', webid: false }) var server = supertest(ldp) From bf3b95ca71e58fbea116d80c75753ad8f274bfaf Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 9 Aug 2017 11:41:47 -0400 Subject: [PATCH 113/178] Add Auth Proxy. --- lib/handlers/auth-proxy.js | 33 ++++++++++++ test/unit/auth-proxy.js | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 lib/handlers/auth-proxy.js create mode 100644 test/unit/auth-proxy.js diff --git a/lib/handlers/auth-proxy.js b/lib/handlers/auth-proxy.js new file mode 100644 index 000000000..a21c3f272 --- /dev/null +++ b/lib/handlers/auth-proxy.js @@ -0,0 +1,33 @@ +// An authentication proxy is a reverse proxy +// that sends a logged-in Solid user's details to a backend +module.exports = addAuthProxyHandlers + +const proxy = require('http-proxy-middleware') +const debug = require('../debug') + +const PROXY_SETTINGS = { + logLevel: 'silent' +} + +// Registers Auth Proxy handlers for each target +function addAuthProxyHandlers (app, targets) { + for (const sourcePath in targets) { + addAuthProxyHandler(app, sourcePath, targets[sourcePath]) + } +} + +// Registers an Auth Proxy handler for the given target +function addAuthProxyHandler (app, sourcePath, target) { + debug.settings(`Add auth proxy from ${sourcePath} to ${target}`) + + // Proxy to the target, removing the source path + // (e.g., /my/proxy/path resolves to http://my.proxy/path) + const sourcePathLength = sourcePath.length + const settings = Object.assign({ + target, + pathRewrite: path => path.substr(sourcePathLength) + }, PROXY_SETTINGS) + + // Activate the proxy + app.use(`${sourcePath}*`, proxy(settings)) +} diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy.js new file mode 100644 index 000000000..b1371574a --- /dev/null +++ b/test/unit/auth-proxy.js @@ -0,0 +1,101 @@ +const authProxy = require('../../lib/handlers/auth-proxy') +const nock = require('nock') +const express = require('express') +const request = require('supertest') +const { expect } = require('chai') + +describe('Auth Proxy', () => { + describe('An auth proxy with 2 destinations', () => { + let app + before(() => { + nock('http://server-a.org').persist() + .get(/./).reply(200, addRequestDetails('a')) + nock('https://server-b.org').persist() + .get(/./).reply(200, addRequestDetails('b')) + + app = express() + authProxy(app, { + '/server/a': 'http://server-a.org', + '/server/b': 'https://server-b.org/foo/bar' + }) + }) + + describe('responding to /server/a', () => { + let response + before(() => { + return request(app).get('/server/a') + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + + describe('responding to /server/a/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/a/my/path?query=string') + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/my/path?query=string') + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + + describe('responding to /server/b', () => { + let response + before(() => { + return request(app).get('/server/b') + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar') + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + + describe('responding to /server/b/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/b/my/path?query=string') + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar/my/path?query=string') + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + }) +}) + +function addRequestDetails (server) { + return function (path) { + return { server, path, headers: this.req.headers } + } +} From 89b613308b24cf6b2ead5a80f0cdd674718c9515 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 9 Aug 2017 12:45:17 -0400 Subject: [PATCH 114/178] Set User header on proxied requests. --- lib/handlers/auth-proxy.js | 9 ++++++ test/unit/auth-proxy.js | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/handlers/auth-proxy.js b/lib/handlers/auth-proxy.js index a21c3f272..601923940 100644 --- a/lib/handlers/auth-proxy.js +++ b/lib/handlers/auth-proxy.js @@ -25,9 +25,18 @@ function addAuthProxyHandler (app, sourcePath, target) { const sourcePathLength = sourcePath.length const settings = Object.assign({ target, + onProxyReq: addUserHeader, + onProxyReqWs: addUserHeader, pathRewrite: path => path.substr(sourcePathLength) }, PROXY_SETTINGS) // Activate the proxy app.use(`${sourcePath}*`, proxy(settings)) } + +// Adds a User header with the user's ID if the user is logged in +function addUserHeader (proxyReq, req) { + if (req.session && req.session.userId) { + proxyReq.setHeader('User', req.session.userId) + } +} diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy.js index b1371574a..2a2af7515 100644 --- a/test/unit/auth-proxy.js +++ b/test/unit/auth-proxy.js @@ -4,9 +4,12 @@ const express = require('express') const request = require('supertest') const { expect } = require('chai') +const USER = 'https://ruben.verborgh.org/profile/#me' + describe('Auth Proxy', () => { describe('An auth proxy with 2 destinations', () => { let app + let loggedIn = true before(() => { nock('http://server-a.org').persist() .get(/./).reply(200, addRequestDetails('a')) @@ -14,6 +17,12 @@ describe('Auth Proxy', () => { .get(/./).reply(200, addRequestDetails('b')) app = express() + app.use((req, res, next) => { + if (loggedIn) { + req.session = { userId: USER } + } + next() + }) authProxy(app, { '/server/a': 'http://server-a.org', '/server/b': 'https://server-b.org/foo/bar' @@ -33,6 +42,11 @@ describe('Auth Proxy', () => { expect(path).to.equal('/') }) + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -51,6 +65,11 @@ describe('Auth Proxy', () => { expect(path).to.equal('/my/path?query=string') }) + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -69,6 +88,11 @@ describe('Auth Proxy', () => { expect(path).to.equal('/foo/bar') }) + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -87,6 +111,38 @@ describe('Auth Proxy', () => { expect(path).to.equal('/foo/bar/my/path?query=string') }) + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + + describe('responding to /server/a without a logged-in user', () => { + let response + before(() => { + loggedIn = false + return request(app).get('/server/a') + .then(res => { response = res }) + }) + after(() => { + loggedIn = true + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('does not set the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.not.have.property('user') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) From 2a14bc7093fda49560d8bda8e94d91a95a6cd066 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 9 Aug 2017 13:30:04 -0400 Subject: [PATCH 115/178] Set Host header on proxied requests. --- lib/handlers/auth-proxy.js | 3 ++- test/unit/auth-proxy.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/handlers/auth-proxy.js b/lib/handlers/auth-proxy.js index 601923940..595b97bb5 100644 --- a/lib/handlers/auth-proxy.js +++ b/lib/handlers/auth-proxy.js @@ -6,7 +6,8 @@ const proxy = require('http-proxy-middleware') const debug = require('../debug') const PROXY_SETTINGS = { - logLevel: 'silent' + logLevel: 'silent', + changeOrigin: true } // Registers Auth Proxy handlers for each target diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy.js index 2a2af7515..a98dca068 100644 --- a/test/unit/auth-proxy.js +++ b/test/unit/auth-proxy.js @@ -4,6 +4,7 @@ const express = require('express') const request = require('supertest') const { expect } = require('chai') +const HOST = 'solid.org' const USER = 'https://ruben.verborgh.org/profile/#me' describe('Auth Proxy', () => { @@ -33,6 +34,7 @@ describe('Auth Proxy', () => { let response before(() => { return request(app).get('/server/a') + .set('Host', HOST) .then(res => { response = res }) }) @@ -47,6 +49,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('user', USER) }) + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -56,6 +63,7 @@ describe('Auth Proxy', () => { let response before(() => { return request(app).get('/server/a/my/path?query=string') + .set('Host', HOST) .then(res => { response = res }) }) @@ -70,6 +78,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('user', USER) }) + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -79,6 +92,7 @@ describe('Auth Proxy', () => { let response before(() => { return request(app).get('/server/b') + .set('Host', HOST) .then(res => { response = res }) }) @@ -93,6 +107,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('user', USER) }) + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -102,6 +121,7 @@ describe('Auth Proxy', () => { let response before(() => { return request(app).get('/server/b/my/path?query=string') + .set('Host', HOST) .then(res => { response = res }) }) @@ -116,6 +136,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('user', USER) }) + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -126,6 +151,7 @@ describe('Auth Proxy', () => { before(() => { loggedIn = false return request(app).get('/server/a') + .set('Host', HOST) .then(res => { response = res }) }) after(() => { @@ -143,6 +169,11 @@ describe('Auth Proxy', () => { expect(headers).to.not.have.property('user') }) + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) From 70f87169576a3b0869cd5c3e817b99385171682c Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 10 Aug 2017 13:33:18 -0400 Subject: [PATCH 116/178] Set Forwarded header on proxied requests. --- lib/handlers/auth-proxy.js | 16 ++++++++++------ test/unit/auth-proxy.js | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/handlers/auth-proxy.js b/lib/handlers/auth-proxy.js index 595b97bb5..e6a8e38ca 100644 --- a/lib/handlers/auth-proxy.js +++ b/lib/handlers/auth-proxy.js @@ -26,8 +26,8 @@ function addAuthProxyHandler (app, sourcePath, target) { const sourcePathLength = sourcePath.length const settings = Object.assign({ target, - onProxyReq: addUserHeader, - onProxyReqWs: addUserHeader, + onProxyReq: addAuthHeaders, + onProxyReqWs: addAuthHeaders, pathRewrite: path => path.substr(sourcePathLength) }, PROXY_SETTINGS) @@ -35,9 +35,13 @@ function addAuthProxyHandler (app, sourcePath, target) { app.use(`${sourcePath}*`, proxy(settings)) } -// Adds a User header with the user's ID if the user is logged in -function addUserHeader (proxyReq, req) { - if (req.session && req.session.userId) { - proxyReq.setHeader('User', req.session.userId) +// Adds a headers with authentication information +function addAuthHeaders (proxyReq, req) { + const { session = {}, headers = {} } = req + if (session.userId) { + proxyReq.setHeader('User', session.userId) + } + if (headers.host) { + proxyReq.setHeader('Forwarded', `host=${headers.host}`) } } diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy.js index a98dca068..7e380df31 100644 --- a/test/unit/auth-proxy.js +++ b/test/unit/auth-proxy.js @@ -54,6 +54,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('host', 'server-a.org') }) + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -83,6 +88,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('host', 'server-a.org') }) + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -112,6 +122,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('host', 'server-b.org') }) + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -141,6 +156,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('host', 'server-b.org') }) + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) @@ -174,6 +194,11 @@ describe('Auth Proxy', () => { expect(headers).to.have.property('host', 'server-a.org') }) + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + it('returns status code 200', () => { expect(response).to.have.property('statusCode', 200) }) From 822d58cb74c3de4d3ae7b10222fbafd65dd16998 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 9 Aug 2017 14:16:32 -0400 Subject: [PATCH 117/178] Add authProxy option. --- bin/lib/options.js | 7 +++++ lib/create-app.js | 10 ++++++- test/integration/auth-proxy.js | 54 ++++++++++++++++++++++++++++++++++ test/unit/auth-proxy.js | 10 ++++++- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/integration/auth-proxy.js diff --git a/bin/lib/options.js b/bin/lib/options.js index 5d789cb82..0cae74865 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -155,6 +155,13 @@ module.exports = [ default: '/proxy', prompt: true }, + { + name: 'authProxy', + help: 'Object with path/server pairs to reverse proxy', + default: {}, + prompt: false, + hide: true + }, { name: 'file-browser', help: 'Type the URL of default app to use for browsing files (or use default)', diff --git a/lib/create-app.js b/lib/create-app.js index 089b56f0c..95546bf19 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -8,6 +8,7 @@ const cors = require('cors') const LDP = require('./ldp') const LdpMiddleware = require('./ldp-middleware') const corsProxy = require('./handlers/cors-proxy') +const authProxy = require('./handlers/auth-proxy') const SolidHost = require('./models/solid-host') const AccountManager = require('./models/account-manager') const vhost = require('vhost') @@ -52,7 +53,7 @@ function createApp (argv = {}) { // Serve the public 'common' directory (for shared CSS files, etc) app.use('/common', express.static('common')) - // Adding proxy + // Add CORS proxy if (argv.proxy) { console.error('The proxy configuration option has been renamed to corsProxy.') argv.corsProxy = argv.proxy @@ -65,14 +66,21 @@ function createApp (argv = {}) { // Options handler app.options('/*', options) + // Set up API if (argv.apiApps) { app.use('/api/apps', express.static(argv.apiApps)) } + // Authenticate the user if (argv.webid) { initWebId(argv, app, ldp) } + // Add Auth proxy (requires authentication) + if (argv.authProxy) { + authProxy(app, argv.authProxy) + } + // Attach the LDP middleware app.use('/', LdpMiddleware(corsSettings)) // Errors diff --git a/test/integration/auth-proxy.js b/test/integration/auth-proxy.js new file mode 100644 index 000000000..ccc88b68f --- /dev/null +++ b/test/integration/auth-proxy.js @@ -0,0 +1,54 @@ +const ldnode = require('../../index') +const path = require('path') +const nock = require('nock') +const request = require('supertest') +const { expect } = require('chai') +const rm = require('../test-utils').rm + +const HOST = 'solid.org' +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Auth Proxy', () => { + describe('A Solid server with the authProxy option', () => { + let server + before(() => { + // Set up test back-end server + nock('http://server-a.org').persist() + .get(/./).reply(200, function () { return this.req.headers }) + + // Set up Solid server + server = ldnode({ + root: path.join(__dirname, '../resources'), + authProxy: { + '/server/a': 'http://server-a.org' + }, + forceUser: USER + }) + }) + + after(() => { + // Release back-end server + nock.cleanAll() + // Remove created index files + rm('index.html') + rm('index.html.acl') + }) + + describe('responding to /server/a', () => { + let response + before(() => { + return request(server).get('/server/a') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('sets the User header on the proxy request', () => { + expect(response.body).to.have.property('user', USER) + }) + + it('returns status code 200', () => { + expect(response).to.have.property('statusCode', 200) + }) + }) + }) +}) diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy.js index 7e380df31..fbd28d4b1 100644 --- a/test/unit/auth-proxy.js +++ b/test/unit/auth-proxy.js @@ -9,14 +9,17 @@ const USER = 'https://ruben.verborgh.org/profile/#me' describe('Auth Proxy', () => { describe('An auth proxy with 2 destinations', () => { - let app let loggedIn = true + + let app before(() => { + // Set up test back-end servers nock('http://server-a.org').persist() .get(/./).reply(200, addRequestDetails('a')) nock('https://server-b.org').persist() .get(/./).reply(200, addRequestDetails('b')) + // Set up proxy server app = express() app.use((req, res, next) => { if (loggedIn) { @@ -30,6 +33,11 @@ describe('Auth Proxy', () => { }) }) + after(() => { + // Release back-end servers + nock.cleanAll() + }) + describe('responding to /server/a', () => { let response before(() => { From 1e6ab2005d36cf2ec8d0a99dcaa0c79e1a2f8172 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 9 Aug 2017 17:35:59 -0400 Subject: [PATCH 118/178] Set default test timeout to 10 seconds. Fixes random errors on Travis. --- test/integration/account-creation-oidc.js | 2 -- test/integration/account-creation-tls.js | 2 -- test/integration/acl-oidc.js | 2 -- test/integration/acl-tls.js | 3 --- test/integration/http-copy.js | 1 - test/mocha.opts | 1 + test/unit/add-cert-request.js | 2 +- 7 files changed, 2 insertions(+), 11 deletions(-) create mode 100644 test/mocha.opts diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc.js index 39f9f21fa..d4cbe66e5 100644 --- a/test/integration/account-creation-oidc.js +++ b/test/integration/account-creation-oidc.js @@ -8,8 +8,6 @@ const path = require('path') const fs = require('fs-extra') describe('AccountManager (OIDC account creation tests)', function () { - this.timeout(10000) - var serverUri = 'https://localhost:3457' var host = 'localhost:3457' var ldpHttpsServer diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls.js index 27376f58f..ea459579f 100644 --- a/test/integration/account-creation-tls.js +++ b/test/integration/account-creation-tls.js @@ -8,8 +8,6 @@ // const path = require('path') // // describe('AccountManager (TLS account creation tests)', function () { -// this.timeout(10000) -// // var address = 'https://localhost:3457' // var host = 'localhost:3457' // var ldpHttpsServer diff --git a/test/integration/acl-oidc.js b/test/integration/acl-oidc.js index 63063ad57..060bfa8b0 100644 --- a/test/integration/acl-oidc.js +++ b/test/integration/acl-oidc.js @@ -37,8 +37,6 @@ const argv = { } describe('ACL HTTP', function () { - this.timeout(10000) - var ldp, ldpHttpsServer before(done => { diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 03fb744f3..fb3fc70a0 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -52,7 +52,6 @@ var userCredentials = { } describe('ACL with WebID+TLS', function () { - this.timeout(10000) var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', @@ -973,8 +972,6 @@ describe('ACL with WebID+TLS', function () { }) describe('ACL with WebID through X-SSL-Cert', function () { - this.timeout(10000) - var ldpHttpsServer before(function (done) { const ldp = ldnode.createServer({ diff --git a/test/integration/http-copy.js b/test/integration/http-copy.js index 20583800b..5856bd43d 100644 --- a/test/integration/http-copy.js +++ b/test/integration/http-copy.js @@ -8,7 +8,6 @@ var rm = require('./../test-utils').rm var solidServer = require('../../index') describe('HTTP COPY API', function () { - this.timeout(10000) var address = 'https://localhost:3456' var ldpHttpsServer diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 000000000..907011807 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1 @@ +--timeout 10000 diff --git a/test/unit/add-cert-request.js b/test/unit/add-cert-request.js index 05d907859..1d1544c12 100644 --- a/test/unit/add-cert-request.js +++ b/test/unit/add-cert-request.js @@ -111,7 +111,7 @@ describe('AddCertificateRequest', () => { expect(graph.anyStatementMatching(key, ns.cert('exponent'))) .to.exist }) - }).timeout(3000) + }) }) }) From 0b5a67555acd7d72fa44593d3b0c00ecae8c6f10 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 11:36:48 -0400 Subject: [PATCH 119/178] Rename main executable to solid. --- bin/solid | 15 +++++++++++++++ bin/solid.js | 16 +--------------- package.json | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) create mode 100755 bin/solid mode change 100755 => 120000 bin/solid.js diff --git a/bin/solid b/bin/solid new file mode 100755 index 000000000..5c4d5c7b0 --- /dev/null +++ b/bin/solid @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +var program = require('commander') +var packageJson = require('../package.json') +var loadInit = require('./lib/init') +var loadStart = require('./lib/start') + +program + .version(packageJson.version) + +loadInit(program) +loadStart(program) + +program.parse(process.argv) +if (program.args.length === 0) program.help() diff --git a/bin/solid.js b/bin/solid.js deleted file mode 100755 index 5c4d5c7b0..000000000 --- a/bin/solid.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node - -var program = require('commander') -var packageJson = require('../package.json') -var loadInit = require('./lib/init') -var loadStart = require('./lib/start') - -program - .version(packageJson.version) - -loadInit(program) -loadStart(program) - -program.parse(process.argv) -if (program.args.length === 0) program.help() diff --git a/bin/solid.js b/bin/solid.js new file mode 120000 index 000000000..e72f8490f --- /dev/null +++ b/bin/solid.js @@ -0,0 +1 @@ +solid \ No newline at end of file diff --git a/package.json b/package.json index 6963a4207..0dd78e6ac 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ }, "main": "index.js", "scripts": { - "solid": "node ./bin/solid.js", + "solid": "node ./bin/solid", "standard": "standard", "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha ./test/**/*.js", "test": "npm run standard && npm run mocha", @@ -129,7 +129,7 @@ ] }, "bin": { - "solid": "./bin/solid.js" + "solid": "./bin/solid" }, "engines": { "node": ">=6.0" From ae946b0358baedc128448312f3b5a2a1b153796d Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 11:43:22 -0400 Subject: [PATCH 120/178] Add solid-test script. --- bin/solid-test | 2 ++ 1 file changed, 2 insertions(+) create mode 100755 bin/solid-test diff --git a/bin/solid-test b/bin/solid-test new file mode 100755 index 000000000..cb07db190 --- /dev/null +++ b/bin/solid-test @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +NODE_TLS_REJECT_UNAUTHORIZED=0 exec `dirname "$0"`/solid $@ From 66e8f8ad1c5eb18200ae28efbca45aa5b65cb738 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 14:02:21 -0400 Subject: [PATCH 121/178] Document solid-test executable. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f65c3cb29..60ecdff04 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,14 @@ $ solid start --root path/to/folder --port 8443 --ssl-key path/to/ssl-key.pem -- # Solid server (solid v0.2.24) running on https://localhost:8443/ ``` +### Running in development environments + +Solid requires SSL certificates to be valid, so you cannot use self-signed certificates. To switch off this security feature in development environments, you can use the `bin/solid-test` executable, which unsets the `NODE_TLS_REJECT_UNAUTHORIZED` flag. If you want to test WebID-TLS authentication with self-signed certificates, additionally set `"rejectUnauthorized": false` in `config.json`. + ##### How do I get an SSL key and certificate? -You need an SSL certificate you get this from your domain provider or for free from [Let's Encrypt!](https://letsencrypt.org/getting-started/). +You need an SSL certificate from a _certificate authority_, such as your domain provider or [Let's Encrypt!](https://letsencrypt.org/getting-started/). -If you don't have one yet, or you just want to test `solid`, generate a certificate (**DO NOT USE IN PRODUCTION**): +For testing purposes, you can use `bin/solid-test` with a _self-signed_ certificate, generated as follows: ``` $ openssl genrsa 2048 > ../localhost.key $ openssl req -new -x509 -nodes -sha256 -days 3650 -key ../localhost.key -subj '/CN=*.localhost' > ../localhost.cert From 4cf7fd58b0eb8a95d8e4af56f169e297f6b013bd Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 14:28:48 -0400 Subject: [PATCH 122/178] Follow Mocha naming conventions. End tests in -test. --- package.json | 21 ++++++++++--------- ...-oidc.js => account-creation-oidc-test.js} | 2 +- ...on-tls.js => account-creation-tls-test.js} | 2 +- ...unt-manager.js => account-manager-test.js} | 0 ...t-template.js => account-template-test.js} | 0 .../{acl-oidc.js => acl-oidc-test.js} | 2 +- .../{acl-tls.js => acl-tls-test.js} | 8 +++---- .../{auth-proxy.js => auth-proxy-test.js} | 2 +- ...on-oidc.js => authentication-oidc-test.js} | 0 ...covery.js => capability-discovery-test.js} | 0 .../{cors-proxy.js => cors-proxy-test.js} | 0 .../{errors-oidc.js => errors-oidc-test.js} | 0 .../integration/{errors.js => errors-test.js} | 8 +++---- .../{formats.js => formats-test.js} | 0 .../{http-copy.js => http-copy-test.js} | 2 +- test/integration/{http.js => http-test.js} | 2 +- test/integration/{ldp.js => ldp-test.js} | 8 +++---- .../{oidc-manager.js => oidc-manager-test.js} | 0 .../integration/{params.js => params-test.js} | 8 +++---- .../{patch-2.js => patch-2-test.js} | 8 +++---- test/integration/{patch.js => patch-test.js} | 8 +++---- test/mocha.opts | 1 + .../emails/{welcome.js => welcome-test.js} | 0 ...unt-manager.js => account-manager-test.js} | 0 ...t-template.js => account-template-test.js} | 0 .../{acl-checker.js => acl-checker-test.js} | 0 ...rt-request.js => add-cert-request-test.js} | 0 ...auth-handlers.js => auth-handlers-test.js} | 0 .../{auth-proxy.js => auth-proxy-test.js} | 0 .../{auth-request.js => auth-request-test.js} | 0 ...authenticator.js => authenticator-test.js} | 0 ...uest.js => create-account-request-test.js} | 0 ...email-service.js => email-service-test.js} | 0 ...email-welcome.js => email-welcome-test.js} | 0 .../{error-pages.js => error-pages-test.js} | 0 .../{force-user.js => force-user-test.js} | 0 ...login-request.js => login-request-test.js} | 0 .../{oidc-manager.js => oidc-manager-test.js} | 0 ...ator.js => password-authenticator-test.js} | 0 ...est.js => password-change-request-test.js} | 0 ...s => password-reset-email-request-test.js} | 0 .../{solid-host.js => solid-host-test.js} | 0 ...enticator.js => tls-authenticator-test.js} | 0 ...token-service.js => token-service-test.js} | 0 .../{user-account.js => user-account-test.js} | 0 ...ounts-api.js => user-accounts-api-test.js} | 0 test/unit/{utils.js => utils-test.js} | 0 test/{test-utils.js => utils.js} | 0 48 files changed, 42 insertions(+), 40 deletions(-) rename test/integration/{account-creation-oidc.js => account-creation-oidc-test.js} (99%) rename test/integration/{account-creation-tls.js => account-creation-tls-test.js} (99%) rename test/integration/{account-manager.js => account-manager-test.js} (100%) rename test/integration/{account-template.js => account-template-test.js} (100%) rename test/integration/{acl-oidc.js => acl-oidc-test.js} (99%) rename test/integration/{acl-tls.js => acl-tls-test.js} (99%) rename test/integration/{auth-proxy.js => auth-proxy-test.js} (97%) rename test/integration/{authentication-oidc.js => authentication-oidc-test.js} (100%) rename test/integration/{capability-discovery.js => capability-discovery-test.js} (100%) rename test/integration/{cors-proxy.js => cors-proxy-test.js} (100%) rename test/integration/{errors-oidc.js => errors-oidc-test.js} (100%) rename test/integration/{errors.js => errors-test.js} (89%) rename test/integration/{formats.js => formats-test.js} (100%) rename test/integration/{http-copy.js => http-copy-test.js} (98%) rename test/integration/{http.js => http-test.js} (99%) rename test/integration/{ldp.js => ldp-test.js} (98%) rename test/integration/{oidc-manager.js => oidc-manager-test.js} (100%) rename test/integration/{params.js => params-test.js} (96%) rename test/integration/{patch-2.js => patch-2-test.js} (97%) rename test/integration/{patch.js => patch-test.js} (97%) rename test/resources/accounts-acl/config/templates/emails/{welcome.js => welcome-test.js} (100%) rename test/unit/{account-manager.js => account-manager-test.js} (100%) rename test/unit/{account-template.js => account-template-test.js} (100%) rename test/unit/{acl-checker.js => acl-checker-test.js} (100%) rename test/unit/{add-cert-request.js => add-cert-request-test.js} (100%) rename test/unit/{auth-handlers.js => auth-handlers-test.js} (100%) rename test/unit/{auth-proxy.js => auth-proxy-test.js} (100%) rename test/unit/{auth-request.js => auth-request-test.js} (100%) rename test/unit/{authenticator.js => authenticator-test.js} (100%) rename test/unit/{create-account-request.js => create-account-request-test.js} (100%) rename test/unit/{email-service.js => email-service-test.js} (100%) rename test/unit/{email-welcome.js => email-welcome-test.js} (100%) rename test/unit/{error-pages.js => error-pages-test.js} (100%) rename test/unit/{force-user.js => force-user-test.js} (100%) rename test/unit/{login-request.js => login-request-test.js} (100%) rename test/unit/{oidc-manager.js => oidc-manager-test.js} (100%) rename test/unit/{password-authenticator.js => password-authenticator-test.js} (100%) rename test/unit/{password-change-request.js => password-change-request-test.js} (100%) rename test/unit/{password-reset-email-request.js => password-reset-email-request-test.js} (100%) rename test/unit/{solid-host.js => solid-host-test.js} (100%) rename test/unit/{tls-authenticator.js => tls-authenticator-test.js} (100%) rename test/unit/{token-service.js => token-service-test.js} (100%) rename test/unit/{user-account.js => user-account-test.js} (100%) rename test/unit/{user-accounts-api.js => user-accounts-api-test.js} (100%) rename test/unit/{utils.js => utils-test.js} (100%) rename test/{test-utils.js => utils.js} (100%) diff --git a/package.json b/package.json index 0dd78e6ac..1630904fb 100644 --- a/package.json +++ b/package.json @@ -98,16 +98,17 @@ "scripts": { "solid": "node ./bin/solid", "standard": "standard", - "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha ./test/**/*.js", - "test": "npm run standard && npm run mocha", - "test-integration": "mocha ./test/integration/*.js", - "test-unit": "mocha ./test/unit/*.js", - "test-debug": "DEBUG='solid:*' ./node_modules/mocha/bin/mocha ./test/*.js", - "test-acl": "./node_modules/mocha/bin/mocha ./test/acl.js", - "test-params": "./node_modules/mocha/bin/mocha ./test/params.js", - "test-http": "./node_modules/mocha/bin/mocha ./test/http.js", - "test-formats": "./node_modules/mocha/bin/mocha ./test/formats.js", - "test-errors": "./node_modules/mocha/bin/mocha ./test/errors.js", + "nyc": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha", + "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 mocha", + "test": "npm run standard && npm run nyc", + "test-integration": "npm run mocha ./test/integration/**/*-test.js", + "test-unit": "npm run mocha ./test/unit/*.js", + "test-debug": "DEBUG='solid:*' npm run mocha", + "test-acl": "npm run mocha ./test/**/*acl*-test.js", + "test-params": "npm run mocha ./test/integration/params-test.js", + "test-http": "npm run mocha ./test/integration/http-test.js", + "test-formats": "npm run mocha ./test/integration/formats-test.js", + "test-errors": "npm run mocha ./test/integration/errors-test.js", "clean": "rm -rf config.json accounts profile inbox .acl settings .meta .meta.acl", "clean-account": "rm -rf accounts profile inbox .acl .meta settings .meta.acl" }, diff --git a/test/integration/account-creation-oidc.js b/test/integration/account-creation-oidc-test.js similarity index 99% rename from test/integration/account-creation-oidc.js rename to test/integration/account-creation-oidc-test.js index d4cbe66e5..34997fc03 100644 --- a/test/integration/account-creation-oidc.js +++ b/test/integration/account-creation-oidc-test.js @@ -2,7 +2,7 @@ const supertest = require('supertest') // Helper functions for the FS const $rdf = require('rdflib') -const { rm, read } = require('../test-utils') +const { rm, read } = require('../utils') const ldnode = require('../../index') const path = require('path') const fs = require('fs-extra') diff --git a/test/integration/account-creation-tls.js b/test/integration/account-creation-tls-test.js similarity index 99% rename from test/integration/account-creation-tls.js rename to test/integration/account-creation-tls-test.js index ea459579f..d345915d9 100644 --- a/test/integration/account-creation-tls.js +++ b/test/integration/account-creation-tls-test.js @@ -2,7 +2,7 @@ // // Helper functions for the FS // const $rdf = require('rdflib') // -// const { rm, read } = require('../test-utils') +// const { rm, read } = require('../utils') // const ldnode = require('../../index') // const fs = require('fs-extra') // const path = require('path') diff --git a/test/integration/account-manager.js b/test/integration/account-manager-test.js similarity index 100% rename from test/integration/account-manager.js rename to test/integration/account-manager-test.js diff --git a/test/integration/account-template.js b/test/integration/account-template-test.js similarity index 100% rename from test/integration/account-template.js rename to test/integration/account-template-test.js diff --git a/test/integration/acl-oidc.js b/test/integration/acl-oidc-test.js similarity index 99% rename from test/integration/acl-oidc.js rename to test/integration/acl-oidc-test.js index 060bfa8b0..23c8322d1 100644 --- a/test/integration/acl-oidc.js +++ b/test/integration/acl-oidc-test.js @@ -2,7 +2,7 @@ const assert = require('chai').assert const fs = require('fs-extra') const request = require('request') const path = require('path') -const rm = require('../test-utils').rm +const rm = require('../utils').rm const ldnode = require('../../index') diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls-test.js similarity index 99% rename from test/integration/acl-tls.js rename to test/integration/acl-tls-test.js index fb3fc70a0..c5a49e861 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls-test.js @@ -10,10 +10,10 @@ var path = require('path') */ // Helper functions for the FS -var rm = require('../test-utils').rm -// var write = require('./test-utils').write -// var cp = require('./test-utils').cp -// var read = require('./test-utils').read +var rm = require('../utils').rm +// var write = require('./utils').write +// var cp = require('./utils').cp +// var read = require('./utils').read var ldnode = require('../../index') var ns = require('solid-namespace')($rdf) diff --git a/test/integration/auth-proxy.js b/test/integration/auth-proxy-test.js similarity index 97% rename from test/integration/auth-proxy.js rename to test/integration/auth-proxy-test.js index ccc88b68f..238696721 100644 --- a/test/integration/auth-proxy.js +++ b/test/integration/auth-proxy-test.js @@ -3,7 +3,7 @@ const path = require('path') const nock = require('nock') const request = require('supertest') const { expect } = require('chai') -const rm = require('../test-utils').rm +const rm = require('../utils').rm const HOST = 'solid.org' const USER = 'https://ruben.verborgh.org/profile/#me' diff --git a/test/integration/authentication-oidc.js b/test/integration/authentication-oidc-test.js similarity index 100% rename from test/integration/authentication-oidc.js rename to test/integration/authentication-oidc-test.js diff --git a/test/integration/capability-discovery.js b/test/integration/capability-discovery-test.js similarity index 100% rename from test/integration/capability-discovery.js rename to test/integration/capability-discovery-test.js diff --git a/test/integration/cors-proxy.js b/test/integration/cors-proxy-test.js similarity index 100% rename from test/integration/cors-proxy.js rename to test/integration/cors-proxy-test.js diff --git a/test/integration/errors-oidc.js b/test/integration/errors-oidc-test.js similarity index 100% rename from test/integration/errors-oidc.js rename to test/integration/errors-oidc-test.js diff --git a/test/integration/errors.js b/test/integration/errors-test.js similarity index 89% rename from test/integration/errors.js rename to test/integration/errors-test.js index eb67ccd5c..8b73d5e2f 100644 --- a/test/integration/errors.js +++ b/test/integration/errors-test.js @@ -2,10 +2,10 @@ var supertest = require('supertest') var path = require('path') // Helper functions for the FS -// var rm = require('./test-utils').rm -// var write = require('./test-utils').write -// var cp = require('./test-utils').cp -var read = require('./../test-utils').read +// var rm = require('./utils').rm +// var write = require('./utils').write +// var cp = require('./utils').cp +var read = require('./../utils').read var ldnode = require('../../index') diff --git a/test/integration/formats.js b/test/integration/formats-test.js similarity index 100% rename from test/integration/formats.js rename to test/integration/formats-test.js diff --git a/test/integration/http-copy.js b/test/integration/http-copy-test.js similarity index 98% rename from test/integration/http-copy.js rename to test/integration/http-copy-test.js index 5856bd43d..56d660a38 100644 --- a/test/integration/http-copy.js +++ b/test/integration/http-copy-test.js @@ -3,7 +3,7 @@ var fs = require('fs') var request = require('request') var path = require('path') // Helper functions for the FS -var rm = require('./../test-utils').rm +var rm = require('./../utils').rm var solidServer = require('../../index') diff --git a/test/integration/http.js b/test/integration/http-test.js similarity index 99% rename from test/integration/http.js rename to test/integration/http-test.js index c81e79167..bfcbcff52 100644 --- a/test/integration/http.js +++ b/test/integration/http-test.js @@ -2,7 +2,7 @@ var supertest = require('supertest') var fs = require('fs') var li = require('li') var ldnode = require('../../index') -var rm = require('./../test-utils').rm +var rm = require('./../utils').rm var path = require('path') const rdf = require('rdflib') diff --git a/test/integration/ldp.js b/test/integration/ldp-test.js similarity index 98% rename from test/integration/ldp.js rename to test/integration/ldp-test.js index de496a6a3..932f4656c 100644 --- a/test/integration/ldp.js +++ b/test/integration/ldp-test.js @@ -6,10 +6,10 @@ var path = require('path') var stringToStream = require('../../lib/utils').stringToStream // Helper functions for the FS -var rm = require('./../test-utils').rm -var write = require('./../test-utils').write -// var cp = require('./test-utils').cp -var read = require('./../test-utils').read +var rm = require('./../utils').rm +var write = require('./../utils').write +// var cp = require('./utils').cp +var read = require('./../utils').read var fs = require('fs') describe('LDP', function () { diff --git a/test/integration/oidc-manager.js b/test/integration/oidc-manager-test.js similarity index 100% rename from test/integration/oidc-manager.js rename to test/integration/oidc-manager-test.js diff --git a/test/integration/params.js b/test/integration/params-test.js similarity index 96% rename from test/integration/params.js rename to test/integration/params-test.js index 00c7f3e4d..71629b3a4 100644 --- a/test/integration/params.js +++ b/test/integration/params-test.js @@ -3,10 +3,10 @@ var supertest = require('supertest') var path = require('path') const fs = require('fs-extra') // Helper functions for the FS -var rm = require('../test-utils').rm -var write = require('../test-utils').write -// var cp = require('./test-utils').cp -var read = require('../test-utils').read +var rm = require('../utils').rm +var write = require('../utils').write +// var cp = require('./utils').cp +var read = require('../utils').read var ldnode = require('../../index') diff --git a/test/integration/patch-2.js b/test/integration/patch-2-test.js similarity index 97% rename from test/integration/patch-2.js rename to test/integration/patch-2-test.js index acfeb71cf..59842bb0d 100644 --- a/test/integration/patch-2.js +++ b/test/integration/patch-2-test.js @@ -4,10 +4,10 @@ var assert = require('chai').assert var path = require('path') // Helper functions for the FS -var rm = require('../test-utils').rm -var write = require('../test-utils').write -// var cp = require('./test-utils').cp -var read = require('../test-utils').read +var rm = require('../utils').rm +var write = require('../utils').write +// var cp = require('./utils').cp +var read = require('../utils').read describe('PATCH', function () { // Starting LDP diff --git a/test/integration/patch.js b/test/integration/patch-test.js similarity index 97% rename from test/integration/patch.js rename to test/integration/patch-test.js index c5247c799..440ba91d6 100644 --- a/test/integration/patch.js +++ b/test/integration/patch-test.js @@ -4,10 +4,10 @@ var assert = require('chai').assert var path = require('path') // Helper functions for the FS -var rm = require('../test-utils').rm -var write = require('../test-utils').write -// var cp = require('./test-utils').cp -var read = require('../test-utils').read +var rm = require('../utils').rm +var write = require('../utils').write +// var cp = require('./utils').cp +var read = require('../utils').read describe('PATCH', function () { // Starting LDP diff --git a/test/mocha.opts b/test/mocha.opts index 907011807..748f8d823 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1 +1,2 @@ +--recursive --timeout 10000 diff --git a/test/resources/accounts-acl/config/templates/emails/welcome.js b/test/resources/accounts-acl/config/templates/emails/welcome-test.js similarity index 100% rename from test/resources/accounts-acl/config/templates/emails/welcome.js rename to test/resources/accounts-acl/config/templates/emails/welcome-test.js diff --git a/test/unit/account-manager.js b/test/unit/account-manager-test.js similarity index 100% rename from test/unit/account-manager.js rename to test/unit/account-manager-test.js diff --git a/test/unit/account-template.js b/test/unit/account-template-test.js similarity index 100% rename from test/unit/account-template.js rename to test/unit/account-template-test.js diff --git a/test/unit/acl-checker.js b/test/unit/acl-checker-test.js similarity index 100% rename from test/unit/acl-checker.js rename to test/unit/acl-checker-test.js diff --git a/test/unit/add-cert-request.js b/test/unit/add-cert-request-test.js similarity index 100% rename from test/unit/add-cert-request.js rename to test/unit/add-cert-request-test.js diff --git a/test/unit/auth-handlers.js b/test/unit/auth-handlers-test.js similarity index 100% rename from test/unit/auth-handlers.js rename to test/unit/auth-handlers-test.js diff --git a/test/unit/auth-proxy.js b/test/unit/auth-proxy-test.js similarity index 100% rename from test/unit/auth-proxy.js rename to test/unit/auth-proxy-test.js diff --git a/test/unit/auth-request.js b/test/unit/auth-request-test.js similarity index 100% rename from test/unit/auth-request.js rename to test/unit/auth-request-test.js diff --git a/test/unit/authenticator.js b/test/unit/authenticator-test.js similarity index 100% rename from test/unit/authenticator.js rename to test/unit/authenticator-test.js diff --git a/test/unit/create-account-request.js b/test/unit/create-account-request-test.js similarity index 100% rename from test/unit/create-account-request.js rename to test/unit/create-account-request-test.js diff --git a/test/unit/email-service.js b/test/unit/email-service-test.js similarity index 100% rename from test/unit/email-service.js rename to test/unit/email-service-test.js diff --git a/test/unit/email-welcome.js b/test/unit/email-welcome-test.js similarity index 100% rename from test/unit/email-welcome.js rename to test/unit/email-welcome-test.js diff --git a/test/unit/error-pages.js b/test/unit/error-pages-test.js similarity index 100% rename from test/unit/error-pages.js rename to test/unit/error-pages-test.js diff --git a/test/unit/force-user.js b/test/unit/force-user-test.js similarity index 100% rename from test/unit/force-user.js rename to test/unit/force-user-test.js diff --git a/test/unit/login-request.js b/test/unit/login-request-test.js similarity index 100% rename from test/unit/login-request.js rename to test/unit/login-request-test.js diff --git a/test/unit/oidc-manager.js b/test/unit/oidc-manager-test.js similarity index 100% rename from test/unit/oidc-manager.js rename to test/unit/oidc-manager-test.js diff --git a/test/unit/password-authenticator.js b/test/unit/password-authenticator-test.js similarity index 100% rename from test/unit/password-authenticator.js rename to test/unit/password-authenticator-test.js diff --git a/test/unit/password-change-request.js b/test/unit/password-change-request-test.js similarity index 100% rename from test/unit/password-change-request.js rename to test/unit/password-change-request-test.js diff --git a/test/unit/password-reset-email-request.js b/test/unit/password-reset-email-request-test.js similarity index 100% rename from test/unit/password-reset-email-request.js rename to test/unit/password-reset-email-request-test.js diff --git a/test/unit/solid-host.js b/test/unit/solid-host-test.js similarity index 100% rename from test/unit/solid-host.js rename to test/unit/solid-host-test.js diff --git a/test/unit/tls-authenticator.js b/test/unit/tls-authenticator-test.js similarity index 100% rename from test/unit/tls-authenticator.js rename to test/unit/tls-authenticator-test.js diff --git a/test/unit/token-service.js b/test/unit/token-service-test.js similarity index 100% rename from test/unit/token-service.js rename to test/unit/token-service-test.js diff --git a/test/unit/user-account.js b/test/unit/user-account-test.js similarity index 100% rename from test/unit/user-account.js rename to test/unit/user-account-test.js diff --git a/test/unit/user-accounts-api.js b/test/unit/user-accounts-api-test.js similarity index 100% rename from test/unit/user-accounts-api.js rename to test/unit/user-accounts-api-test.js diff --git a/test/unit/utils.js b/test/unit/utils-test.js similarity index 100% rename from test/unit/utils.js rename to test/unit/utils-test.js diff --git a/test/test-utils.js b/test/utils.js similarity index 100% rename from test/test-utils.js rename to test/utils.js From a1a36c7fee69a09b0bc7cf47478377a6fa47aaea Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 14:45:15 -0400 Subject: [PATCH 123/178] Remove specific test commands. --- package.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package.json b/package.json index 1630904fb..e76b5ff94 100644 --- a/package.json +++ b/package.json @@ -101,14 +101,6 @@ "nyc": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha", "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 mocha", "test": "npm run standard && npm run nyc", - "test-integration": "npm run mocha ./test/integration/**/*-test.js", - "test-unit": "npm run mocha ./test/unit/*.js", - "test-debug": "DEBUG='solid:*' npm run mocha", - "test-acl": "npm run mocha ./test/**/*acl*-test.js", - "test-params": "npm run mocha ./test/integration/params-test.js", - "test-http": "npm run mocha ./test/integration/http-test.js", - "test-formats": "npm run mocha ./test/integration/formats-test.js", - "test-errors": "npm run mocha ./test/integration/errors-test.js", "clean": "rm -rf config.json accounts profile inbox .acl settings .meta .meta.acl", "clean-account": "rm -rf accounts profile inbox .acl .meta settings .meta.acl" }, From ad093987b69a13e1206e7b463135e76b40806086 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 11 Aug 2017 14:47:00 -0400 Subject: [PATCH 124/178] Remove clean scripts. Not needed anymore because of 3659b65f6369eb901dc408575caf2796858ce8e0. --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index e76b5ff94..f1ee872d6 100644 --- a/package.json +++ b/package.json @@ -100,9 +100,7 @@ "standard": "standard", "nyc": "NODE_TLS_REJECT_UNAUTHORIZED=0 nyc mocha", "mocha": "NODE_TLS_REJECT_UNAUTHORIZED=0 mocha", - "test": "npm run standard && npm run nyc", - "clean": "rm -rf config.json accounts profile inbox .acl settings .meta .meta.acl", - "clean-account": "rm -rf accounts profile inbox .acl .meta settings .meta.acl" + "test": "npm run standard && npm run nyc" }, "nyc": { "reporter": [ From e2730328f04d9326f0e5a75d79d1f4722c7324d9 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Fri, 26 May 2017 16:36:44 -0400 Subject: [PATCH 125/178] Add a 'two pods plus external web app' integration test --- package.json | 4 +- test/integration/acl-oidc-test.js | 1 + test/integration/authentication-oidc-test.js | 197 ++++++++++++++++-- .../tim.localhost/multi-server/protected.txt | 1 + .../multi-server/protected.txt.acl | 8 + test/resources/accounts-scenario/bob/foo | 1 - test/resources/accounts-scenario/bob/foo.acl | 5 - .../bob/shared-with-alice.txt | 1 + .../bob/shared-with-alice.txt.acl | 15 ++ .../external-servers/example.com/jwks.json | 81 +++++++ .../example.com/openid-configuration.json | 53 +++++ 11 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 test/resources/accounts-acl/tim.localhost/multi-server/protected.txt create mode 100644 test/resources/accounts-acl/tim.localhost/multi-server/protected.txt.acl delete mode 100644 test/resources/accounts-scenario/bob/foo delete mode 100644 test/resources/accounts-scenario/bob/foo.acl create mode 100644 test/resources/accounts-scenario/bob/shared-with-alice.txt create mode 100644 test/resources/accounts-scenario/bob/shared-with-alice.txt.acl create mode 100644 test/resources/external-servers/example.com/jwks.json create mode 100644 test/resources/external-servers/example.com/openid-configuration.json diff --git a/package.json b/package.json index f1ee872d6..f90efcc44 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mime-types": "^2.1.11", "moment": "^2.18.1", "negotiator": "^0.6.0", - "node-fetch": "^1.6.3", + "node-fetch": "^1.7.1", "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", @@ -84,6 +84,7 @@ "chai-as-promised": "^6.0.0", "dirty-chai": "^1.2.2", "hippie": "^0.5.0", + "localstorage-memory": "^1.0.2", "mocha": "^3.2.0", "nock": "^9.0.14", "node-mocks-http": "^1.5.6", @@ -91,6 +92,7 @@ "proxyquire": "^1.7.10", "sinon": "^2.1.0", "sinon-chai": "^2.8.0", + "solid-auth-oidc": "^0.1.3", "standard": "^8.6.0", "supertest": "^3.0.0" }, diff --git a/test/integration/acl-oidc-test.js b/test/integration/acl-oidc-test.js index 23c8322d1..ce4bd640f 100644 --- a/test/integration/acl-oidc-test.js +++ b/test/integration/acl-oidc-test.js @@ -3,6 +3,7 @@ const fs = require('fs-extra') const request = require('request') const path = require('path') const rm = require('../utils').rm +const nock = require('nock') const ldnode = require('../../index') diff --git a/test/integration/authentication-oidc-test.js b/test/integration/authentication-oidc-test.js index d3a6c2a6f..ade123275 100644 --- a/test/integration/authentication-oidc-test.js +++ b/test/integration/authentication-oidc-test.js @@ -1,11 +1,21 @@ const Solid = require('../../index') const path = require('path') -const supertest = require('supertest') -const expect = require('chai').expect -const nock = require('nock') const fs = require('fs-extra') const { UserStore } = require('oidc-auth-manager') const UserAccount = require('../../lib/models/user-account') +const SolidAuthOIDC = require('solid-auth-oidc') + +const fetch = require('node-fetch') +const localStorage = require('localstorage-memory') +const url = require('url') +const { URL } = url +global.URL = URL + +const supertest = require('supertest') +const nock = require('nock') +const chai = require('chai') +const expect = chai.expect +chai.use(require('dirty-chai')) // In this test we always assume that we are Alice @@ -270,11 +280,11 @@ describe('Authentication API (OIDC)', () => { }) }) - describe('Login workflow', () => { - // Step 1: Alice tries to access bob.com/foo, and + describe('Two Pods + Browser Login workflow', () => { + // Step 1: Alice tries to access bob.com/shared-with-alice.txt, and // gets redirected to bob.com's Provider Discovery endpoint it('401 Unauthorized -> redirect to provider discovery', (done) => { - bob.get('/foo') + bob.get('/shared-with-alice.txt') .expect(401) .end((err, res) => { if (err) return done(err) @@ -285,15 +295,178 @@ describe('Authentication API (OIDC)', () => { }) }) - // Step 2: Alice enters her WebID URI to the Provider Discovery endpoint - it('Enter webId -> redirect to provider login', (done) => { - bob.post('/api/auth/select-provider') + // Step 2: Alice enters her pod's URI to Bob's Provider Discovery endpoint + it('Enter webId -> redirect to provider login', () => { + return bob.post('/api/auth/select-provider') .send('webid=' + aliceServerUri) .expect(302) - .end((err, res) => { + .then(res => { + // Submitting select-provider form redirects to Alice's pod's /authorize + let authorizeUri = res.header.location + expect(authorizeUri.startsWith(aliceServerUri + '/authorize')) + + // Follow the redirect to /authorize + let authorizePath = url.parse(authorizeUri).path + return alice.get(authorizePath) + }) + .then(res => { + // Since alice not logged in to her pod, /authorize redirects to /login let loginUri = res.header.location - expect(loginUri.startsWith(aliceServerUri + '/authorize')) - done(err) + expect(loginUri.startsWith('/login')) + }) + }) + }) + + describe('Two Pods + Web App Login Workflow', () => { + let aliceAccount = UserAccount.from({ webId: aliceWebId }) + let alicePassword = '12345' + + let auth + let authorizationUri, loginUri, authParams, callbackUri + let loginFormFields = '' + + before(() => { + auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) + let appOptions = { + redirectUri: 'https://app.example.com/callback' + } + + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .then(() => { + return auth.registerClient(aliceServerUri, appOptions) + }) + .then(registeredClient => { + auth.currentClient = registeredClient + }) + }) + + after(() => { + fs.removeSync(path.join(aliceDbPath, 'users/users')) + fs.removeSync(path.join(aliceDbPath, 'oidc/op/tokens')) + + let clientId = auth.currentClient.registration['client_id'] + let registration = `_key_${clientId}.json` + fs.removeSync(path.join(aliceDbPath, 'oidc/op/clients', registration)) + }) + + // Step 1: An app makes a GET request and receives a 401 + it('should get a 401 error on a REST request to a protected resource', () => { + return fetch(bobServerUri + '/shared-with-alice.txt') + .then(res => { + expect(res.status).to.equal(401) + + expect(res.headers.get('www-authenticate')) + .to.equal(`Bearer realm="${bobServerUri}", scope="openid"`) + }) + }) + + // Step 2: App presents the Select Provider UI to user, determine the + // preferred provider uri (here, aliceServerUri), and constructs + // an authorization uri for that provider + it('should determine the authorization uri for a preferred provider', () => { + return auth.currentClient.createRequest({}, auth.store) + .then(authUri => { + authorizationUri = authUri + + expect(authUri.startsWith(aliceServerUri + '/authorize')).to.be.true() + }) + }) + + // Step 3: App redirects user to the authorization uri for login + it('should redirect user to /authorize and /login', () => { + return fetch(authorizationUri, { redirect: 'manual' }) + .then(res => { + // Since user is not logged in, /authorize redirects to /login + expect(res.status).to.equal(302) + + loginUri = new URL(res.headers.get('location')) + expect(loginUri.toString().startsWith(aliceServerUri + '/login')) + .to.be.true() + + authParams = loginUri.searchParams + }) + }) + + // Step 4: Pod returns a /login page with appropriate hidden form fields + it('should display the /login form', () => { + return fetch(loginUri.toString()) + .then(loginPage => { + return loginPage.text() + }) + .then(pageText => { + // Login page should contain the relevant auth params as hidden fields + + authParams.forEach((value, key) => { + let hiddenField = `<input type="hidden" name="${key}" id="${key}" value="${value}" />` + + expect(pageText).to.match(new RegExp(hiddenField)) + + loginFormFields += `${key}=` + encodeURIComponent(value) + '&' + }) + }) + }) + + // Step 5: User submits their username & password via the /login form + it('should login via the /login form', () => { + loginFormFields += `username=${'alice'}&password=${alicePassword}` + + return fetch(aliceServerUri + '/login/password', { + method: 'POST', + body: loginFormFields, + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + credentials: 'include' + }) + .then(res => { + expect(res.status).to.equal(302) + let postLoginUri = res.headers.get('location') + let cookie = res.headers.get('set-cookie') + + // Successful login gets redirected back to /authorize and then + // back to app + expect(postLoginUri.startsWith(aliceServerUri + '/authorize')) + .to.be.true() + + return fetch(postLoginUri, { redirect: 'manual', headers: { cookie } }) + }) + .then(res => { + // User gets redirected back to original app + expect(res.status).to.equal(302) + callbackUri = res.headers.get('location') + expect(callbackUri.startsWith('https://app.example.com#')) + + expect(res.headers.get('user')).to.equal(aliceWebId) + }) + }) + + // Step 6: Web App extracts tokens from the uri hash fragment, uses + // them to access protected resource + it('should use id token from the callback uri to access shared resource', () => { + auth.window.location.href = callbackUri + + return auth.initUserFromResponse(auth.currentClient) + .then(webId => { + expect(webId).to.equal(aliceWebId) + + let idToken = auth.idToken + + return fetch(bobServerUri + '/shared-with-alice.txt', { + headers: { + 'Authorization': 'Bearer ' + idToken + } + }) + }) + .then(res => { + expect(res.status).to.equal(200) + + return res.text() + }) + .then(contents => { + expect(contents).to.equal('protected contents\n') }) }) }) diff --git a/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt b/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt new file mode 100644 index 000000000..ed4a651ad --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt @@ -0,0 +1 @@ +protected resource diff --git a/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt.acl b/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt.acl new file mode 100644 index 000000000..8f12a2386 --- /dev/null +++ b/test/resources/accounts-acl/tim.localhost/multi-server/protected.txt.acl @@ -0,0 +1,8 @@ +<#0> + a <http://www.w3.org/ns/auth/acl#Authorization>; + <http://www.w3.org/ns/auth/acl#accessTo> <./protected.txt> ; + + <http://www.w3.org/ns/auth/acl#agent> <https://alice.example.com/profile/card#me> ; + + <http://www.w3.org/ns/auth/acl#mode> + <http://www.w3.org/ns/auth/acl#Write>, <http://www.w3.org/ns/auth/acl#Read>. diff --git a/test/resources/accounts-scenario/bob/foo b/test/resources/accounts-scenario/bob/foo deleted file mode 100644 index 191028156..000000000 --- a/test/resources/accounts-scenario/bob/foo +++ /dev/null @@ -1 +0,0 @@ -foo \ No newline at end of file diff --git a/test/resources/accounts-scenario/bob/foo.acl b/test/resources/accounts-scenario/bob/foo.acl deleted file mode 100644 index 4cf18c1c8..000000000 --- a/test/resources/accounts-scenario/bob/foo.acl +++ /dev/null @@ -1,5 +0,0 @@ -<#Alice> - a <http://www.w3.org/ns/auth/acl#Authorization> ; - <http://www.w3.org/ns/auth/acl#accessTo> <./foo>; - <http://www.w3.org/ns/auth/acl#agent> <https://127.0.0.1:5000/profile/card#me>; - <http://www.w3.org/ns/auth/acl#mode> <http://www.w3.org/ns/auth/acl#Read>, <http://www.w3.org/ns/auth/acl#Write>, <http://www.w3.org/ns/auth/acl#Control> . \ No newline at end of file diff --git a/test/resources/accounts-scenario/bob/shared-with-alice.txt b/test/resources/accounts-scenario/bob/shared-with-alice.txt new file mode 100644 index 000000000..304c0b4f3 --- /dev/null +++ b/test/resources/accounts-scenario/bob/shared-with-alice.txt @@ -0,0 +1 @@ +protected contents diff --git a/test/resources/accounts-scenario/bob/shared-with-alice.txt.acl b/test/resources/accounts-scenario/bob/shared-with-alice.txt.acl new file mode 100644 index 000000000..e454c2215 --- /dev/null +++ b/test/resources/accounts-scenario/bob/shared-with-alice.txt.acl @@ -0,0 +1,15 @@ +<#Alice> + a <http://www.w3.org/ns/auth/acl#Authorization> ; + + <http://www.w3.org/ns/auth/acl#accessTo> <./shared-with-alice.txt>; + + # Alice web id + <http://www.w3.org/ns/auth/acl#agent> <https://localhost:7000/profile/card#me>; + + # Bob web id + <http://www.w3.org/ns/auth/acl#agent> <https://localhost:7001/profile/card#me>; + + <http://www.w3.org/ns/auth/acl#mode> + <http://www.w3.org/ns/auth/acl#Read>, + <http://www.w3.org/ns/auth/acl#Write>, + <http://www.w3.org/ns/auth/acl#Control> . diff --git a/test/resources/external-servers/example.com/jwks.json b/test/resources/external-servers/example.com/jwks.json new file mode 100644 index 000000000..eb8130fa8 --- /dev/null +++ b/test/resources/external-servers/example.com/jwks.json @@ -0,0 +1,81 @@ +{ + "keys": [ + { + "kid": "2koDA6QjhXU", + "kty": "RSA", + "alg": "RS256", + "n": "wcO-8ub-aAf1LoH3TjX1HtlYhc_AHkIxgSwFJKjF8eY3sUpkzfS_lsBYoerG-1gJVP-j5vrGNfre7lFjUd-TukKMBnONZBnER8RSbbIC2MuoUpEj6cWoL5gD1WIkznFw_tO5w6bf2kqL2GR1_GbWAYmfOJFd_lJwg6eciNzYqvDwx-hZniNqTAD63y4od1mcKJBxFXY83VdFcCCWitg37Uxeyw8qTAQgOkR258a5juU9n8y3GDWYeWKkpr9dLWJaWomI6x-dL_tROwSMcuISMpGftGf7pYN83DQBDSwXPkaQnd1g7ExSb3slSdf_Z1kTH5eRoGJdXuA7lmRpUHDrUw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "9CdpWhTmRwQ", + "kty": "RSA", + "alg": "RS384", + "n": "riNXxT2bQG17gV8Gp28f1fZvBoA-iO85lfZncMflXJNbkTR69rbqsYOPbJ02BIvdbBk9cdCSHDDO_yH2FAnY1N0WONRcQdVkyKcfCS8gLpFDRnP7sa5_WOwnrnY00VEHpPUhcUWzTK2pNSZemGY14fPgNDW7vH8dipMfVMr9bgmvPzefgEIeANOMKA6PHx_0WcT9k8NYjDdpuo6loFmVTj5ulWNO4rVTLFCMtyTB1cwNeIeN0Kwmqcuta5Y_FiEMpP_Hw8MBdoIZfH2P7qa6lkbw_jExY1suyP5BxU6cUndzjcIeiBiVbJEPCvR1zBuxNUADFqOCEF-8fIsY8AL6fw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "lVZq8jBYVa4", + "kty": "RSA", + "alg": "RS512", + "n": "tQz5GDyEZQdJlPq4aiKHlu9_gEft8ozIbi-tbmx_0JPUHOvZwPaWkPERu27MHNp7Z4XvftkyUXH243Prtetjq6cUd4FiYyOz6MpbktxOXfl8oSfbe89Dava4PqgolJaTBfp21WsMM9OvweOgto1Rv9do7oUsoRQIuHl17T2RmMXx4AE2CDyPA7JQCDtw6zk6erLdIZtUrF0J1UrGFHRHjsFexRIPc07X9IIMqzQlESlJXCbiVEvCteMCZbYuhbvvNqiTlNLUh4_jO_7NP6zkhwnRm5bJ4pXGrEMUi9FypRiVABkRZigvK19RDrsUA3AXt7VMkVRtXSsmv_YtmzVJgw", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "Zr1pAM5uXRI", + "kty": "RSA", + "alg": "RS256", + "n": "4nLfuOakIfP4YRrVGopRNszYlcnw4GhYwedQvt-CgAc5-1fLqi7n4Dr2vyeNB79h9cIVk_i4ehB5M8EcZN0OHHpDcTrYJOS0gyOILDwQhQezc6VRcor6g0jq-VuMFWNCXcnlowAWJ09d8c_CgNFoJsIvFji4cIeBIFYh3bpJMKQpxccYh8D12jRYQckTvmhZQhifFPYSu_YFk7R2eOFu4nuf-xZqzBKp2zFE28VPgBX_i4BV0vR5Mdl1UmnZ_LZbyseH9sIzZmVHGTCQ-5DYqFlXXBNPGs4q5qku9hN6qSmgT0yypYwwZYRG-XAx79ZSZMIFUiG_hWrc3SWPlgV9SQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "C_ZedndjY2M", + "kty": "RSA", + "alg": "RS384", + "n": "vxCys4nxNi56-VtzYdiH7xYhT95CC2oaLDlFIY216O5VoQYrMQvwdZvRPGpKepxyY6xKILki7jB3BjbF3honf5M7MK-i6oQRXS6HCdruBGp6XZSAw7yemn1sP4f8MRrhJ2B130YIvhKLuQekdCLR--_n6-WfZZuUF7AXKcs5XdPW3vSy_XLAtnso8axmYASfhNK6DwOwXTA-uJTHW1HVfALUfgtzxAt7Z92ySnuI_CzXnr6lt-vDd52leaCS8yso8Anpa7xXJC3czkRFFrN20k5m6olhUpssnSVJDLyWup7PInfRQCNzuQgpomWFh9r3hwyrQMKSrliTIOiSRq7l6Q", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "rB8CEEEM6qI", + "kty": "RSA", + "alg": "RS512", + "n": "s760vqQPvEf82T4LHPby9hxFFIKUda0G79xuDmseWJgeMWlU3yv6uDPPJ2Nx-4prXC_fiFlNsEEx8p6GjKpAi32Mb1vehqTNJEmluxLrBeYYY5-mA5d8-2BE_5jLSkDEQ8pFwWICP-LbI95U7aytMqnuTqPr8cC4N2Sac2P4r8lGrzT6F276DB2Meshf00lJ9o_7ta77rLTVBCo9Ws5D9V7JiT86mx8hia_6JbvcmfgH_6NAitGMD6tvXNWH-i7o8ZLywBJO4U35wU_woduTFmj_ZFDrBgMRNMVBrvnwk_5XDfbrVkRLaunnQMadKgCMkryj4RXNqms08wF8N59uLQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + }, + { + "kid": "Muh91OOgi5Q", + "kty": "RSA", + "alg": "RS256", + "n": "wbiBktBKqfKFnXHuBrxeN09D006kX6C9VUykaQayPZJCmSv8X8zpCclOXCYHboGtkJ9y5E9iCCK0V7kFvSXOWl562tWHlNzZfZM6xZU1hS-1jFc3Hjk66yIkeocFpAdb3pUCzFmSNrQsDWoJSJa-ly6AkmPahPe2A7UzFeEjUiWKossssOhgvo3TFCB6D7kkU7DujShm74FqzjXEPmcgc3ZDzpALu7N_HqxIbuQv0TJ7yIY8cqzyTmDahy0cKGn1Z4ViVwCCZsgVniDLbcLcsXkhPWKAtM2FMLbSIJvEZrLlPFTWWsc82oky5u2aeO0hEodihkwVl-w_Xaiv3RZVmQ", + "e": "AQAB", + "key_ops": [ + "verify" + ], + "ext": true + } + ] +} diff --git a/test/resources/external-servers/example.com/openid-configuration.json b/test/resources/external-servers/example.com/openid-configuration.json new file mode 100644 index 000000000..5075458b3 --- /dev/null +++ b/test/resources/external-servers/example.com/openid-configuration.json @@ -0,0 +1,53 @@ +{ + "issuer": "https://example.com", + "authorization_endpoint": "https://example.com/authorize", + "token_endpoint": "https://example.com/token", + "userinfo_endpoint": "https://example.com/userinfo", + "jwks_uri": "https://example.com/jwks", + "registration_endpoint": "https://example.com/register", + "response_types_supported": [ + "code", + "code token", + "code id_token", + "id_token", + "id_token token", + "code id_token token", + "none" + ], + "response_modes_supported": [ + "query", + "fragment" + ], + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "client_credentials" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256", + "RS384", + "RS512", + "none" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256" + ], + "display_values_supported": [ ], + "claim_types_supported": [ + "normal" + ], + "claims_supported": "", + "claims_parameter_supported": false, + "request_parameter_supported": false, + "request_uri_parameter_supported": true, + "require_request_uri_registration": false, + "check_session_iframe": "https://example.com/session", + "end_session_endpoint": "https://example.com/logout" +} From f8db96bc792d8061313b0ae9c28bd8e2cd326577 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Mon, 14 Aug 2017 10:26:33 -0400 Subject: [PATCH 126/178] Add support for Proof of Possession tokens, update tests --- lib/api/authn/webid-oidc.js | 7 ++- lib/handlers/error-pages.js | 1 + lib/requests/login-request.js | 2 +- package.json | 8 +-- test/integration/acl-oidc-test.js | 51 ++++++++++++++++--- test/integration/authentication-oidc-test.js | 20 +++++--- test/integration/errors-oidc-test.js | 2 +- .../accounts-acl/db/oidc/op/provider.json | 6 +-- .../_key_https%3A%2F%2Flocalhost%3A7777.json | 2 +- .../alice/db/oidc/op/provider.json | 6 +-- .../_key_https%3A%2F%2Flocalhost%3A7000.json | 2 +- .../bob/db/oidc/op/provider.json | 6 +-- .../_key_https%3A%2F%2Flocalhost%3A7000.json | 2 +- .../_key_https%3A%2F%2Flocalhost%3A7001.json | 2 +- .../accounts/db/oidc/op/provider.json | 8 +-- .../_key_https%3A%2F%2Flocalhost%3A3457.json | 2 +- .../example.com/openid-configuration.json | 6 +-- test/unit/login-request-test.js | 10 ++-- test/utils.js | 17 +++++++ 19 files changed, 114 insertions(+), 46 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index e046fc949..0ba1ae2c8 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -31,8 +31,13 @@ function initialize (app, argv) { // Attach the OIDC API app.use('/', middleware(oidc)) + // Perform the actual authentication - app.use('/', oidc.rs.authenticate()) + let rsOptions = { + allow: { audience: [app.locals.host.serverUri] } + } + app.use('/', oidc.rs.authenticate(rsOptions)) + // Expose session.userId app.use('/', (req, res, next) => { const userId = oidc.webIdFromClaims(req.claims) diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index f099caad3..f028190c0 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -31,6 +31,7 @@ function handler (err, req, res, next) { let statusCode = statusCodeFor(err, req, authMethod) if (statusCode === 401) { + debug(err, 'error:', err.error, 'desc:', err.error_description) setAuthenticateHeader(req, res, err) } diff --git a/lib/requests/login-request.js b/lib/requests/login-request.js index 7ce699f15..c78aa4ea8 100644 --- a/lib/requests/login-request.js +++ b/lib/requests/login-request.js @@ -157,7 +157,7 @@ class LoginRequest extends AuthRequest { postLoginUrl (validUser) { let uri - if (this.authQueryParams['redirect_uri']) { + if (this.authQueryParams['client_id']) { // Login request is part of an app's auth flow uri = this.authorizeUrl() } else if (validUser) { diff --git a/package.json b/package.json index f90efcc44..01acb625e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.7.3", + "oidc-auth-manager": "^0.8.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", @@ -80,6 +80,7 @@ "x509": "^0.3.2" }, "devDependencies": { + "@trust/oidc-op": "^0.3.0", "chai": "^3.5.0", "chai-as-promised": "^6.0.0", "dirty-chai": "^1.2.2", @@ -92,9 +93,10 @@ "proxyquire": "^1.7.10", "sinon": "^2.1.0", "sinon-chai": "^2.8.0", - "solid-auth-oidc": "^0.1.3", + "solid-auth-oidc": "^0.2.0", "standard": "^8.6.0", - "supertest": "^3.0.0" + "supertest": "^3.0.0", + "whatwg-url": "^6.1.0" }, "main": "index.js", "scripts": { diff --git a/test/integration/acl-oidc-test.js b/test/integration/acl-oidc-test.js index ce4bd640f..15078b032 100644 --- a/test/integration/acl-oidc-test.js +++ b/test/integration/acl-oidc-test.js @@ -2,8 +2,8 @@ const assert = require('chai').assert const fs = require('fs-extra') const request = require('request') const path = require('path') -const rm = require('../utils').rm -const nock = require('nock') +const { loadProvider, rm } = require('../utils') +const IDToken = require('@trust/oidc-op/src/IDToken') const ldnode = require('../../index') @@ -11,15 +11,35 @@ const port = 7777 const serverUri = `https://localhost:7777` const rootPath = path.join(__dirname, '../resources/accounts-acl') const dbPath = path.join(rootPath, 'db') +const oidcProviderPath = path.join(dbPath, 'oidc', 'op', 'provider.json') const configPath = path.join(rootPath, 'config') const user1 = 'https://tim.localhost:7777/profile/card#me' const timAccountUri = 'https://tim.localhost:7777' const user2 = 'https://nicola.localhost:7777/profile/card#me' -const userCredentials = { - user1: 'eyJhbGciOiJSUzI1NiIsImtpZCI6IkFWUzVlZk5pRUVNIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiaHR0cHM6Ly90aW0ubG9jYWxob3N0Ojc3NzcvcHJvZmlsZS9jYXJkI21lIiwiYXVkIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJleHAiOjc3OTkyMjkwMDksImlhdCI6MTQ5MjAyOTAwOSwianRpIjoiZWY3OGQwYjY3ZWRjNzJhMSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.H9lxCbNc47SfIq3hhHnj48BE-YFnvhCfDH9Jc4PptApTEip8sVj0E_u704K_huhNuWBvuv3cDRDGYZM7CuLnzgJG1BI75nXR9PYAJPK9Ketua2KzIrftNoyKNamGqkoCKFafF4z_rsmtXQ5u1_60SgWRcouXMpcHnnDqINF1JpvS21xjE_LbJ6qgPEhu3rRKcv1hpRdW9dRvjtWb9xu84bAjlRuT02lyDBHgj2utxpE_uqCbj48qlee3GoqWpGkSS-vJ6JA0aWYgnyv8fQsxf9rpdFNzKRoQO6XYMy6niEKj8aKgxjaUlpoGGJ5XtVLHH8AGwjYXR8iznYzJvEcB7Q', - user2: 'eyJhbGciOiJSUzI1NiIsImtpZCI6IkFWUzVlZk5pRUVNIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiaHR0cHM6Ly9uaWNvbGEubG9jYWxob3N0Ojc3NzcvcHJvZmlsZS9jYXJkI21lIiwiYXVkIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJleHAiOjc3OTkyMjkwMDksImlhdCI6MTQ5MjAyOTAwOSwianRpIjoiMmQwOTJlZGVkOWI5YTQ5ZSIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUifQ.qs-_pZPZZzaK_pIOQr-T3yMxVPo1Z5R-TwIi_a4Q4Arudu2s9VkoPmsfsCeVc22i6I1uLiaRe_9qROpXd-Oiy0dsMMEtqyQWcc0zxp3RYQs99sAi4pTPOsTjtJwsMRJp4n8nx_TWQ7mS1grZEdSLr53v-2QqTZXVW8cBu4vQ0slXWsKsuaySk-hCMnxk7vHj70uFpuKRjx4CBHkEWXooEyXgcmS8QR-d_peq8Ldkq1Bez4SAQ9sy_4UVaIWoLRqA7gr0Grh7OTHZNdYV_NJoH0mnbCuyS5N5YEI8QuUzuYlSNhgZ_cZ3j1uqw_fs8SIHFtWMghdnT2JdRKUFfn4-vA' +let oidcProvider + +// To be initialized in the before() block +let userCredentials = { + // idp: https://localhost:7777 + // web id: https://tim.localhost:7777/profile/card#me + user1: '', + // web id: https://nicola.localhost:7777/profile/card#me + user2: '' +} + +function issueIdToken (oidcProvider, webId) { + return Promise.resolve() + .then(() => { + let jwt = IDToken.issue(oidcProvider, { + sub: webId, + aud: [ serverUri, 'client123' ], + azp: 'client123' + }) + + return jwt.encode() + }) } const argv = { @@ -38,11 +58,28 @@ const argv = { } describe('ACL HTTP', function () { - var ldp, ldpHttpsServer + let ldp, ldpHttpsServer before(done => { ldp = ldnode.createServer(argv) - ldpHttpsServer = ldp.listen(port, done) + + loadProvider(oidcProviderPath) + .then(provider => { + oidcProvider = provider + + return Promise.all([ + issueIdToken(oidcProvider, user1), + issueIdToken(oidcProvider, user2) + ]) + }) + .then(tokens => { + userCredentials.user1 = tokens[0] + userCredentials.user2 = tokens[1] + }) + .then(() => { + ldpHttpsServer = ldp.listen(port, done) + }) + .catch(console.error) }) after(() => { diff --git a/test/integration/authentication-oidc-test.js b/test/integration/authentication-oidc-test.js index ade123275..3ffd28250 100644 --- a/test/integration/authentication-oidc-test.js +++ b/test/integration/authentication-oidc-test.js @@ -8,8 +8,9 @@ const SolidAuthOIDC = require('solid-auth-oidc') const fetch = require('node-fetch') const localStorage = require('localstorage-memory') const url = require('url') -const { URL } = url +const URL = require('whatwg-url').URL global.URL = URL +global.URLSearchParams = require('whatwg-url').URLSearchParams const supertest = require('supertest') const nock = require('nock') @@ -358,7 +359,7 @@ describe('Authentication API (OIDC)', () => { expect(res.status).to.equal(401) expect(res.headers.get('www-authenticate')) - .to.equal(`Bearer realm="${bobServerUri}", scope="openid"`) + .to.equal(`Bearer realm="${bobServerUri}", scope="openid webid"`) }) }) @@ -401,7 +402,9 @@ describe('Authentication API (OIDC)', () => { authParams.forEach((value, key) => { let hiddenField = `<input type="hidden" name="${key}" id="${key}" value="${value}" />` - expect(pageText).to.match(new RegExp(hiddenField)) + let fieldRegex = new RegExp(hiddenField) + + expect(pageText).to.match(fieldRegex) loginFormFields += `${key}=` + encodeURIComponent(value) + '&' }) @@ -448,15 +451,18 @@ describe('Authentication API (OIDC)', () => { it('should use id token from the callback uri to access shared resource', () => { auth.window.location.href = callbackUri + let protectedResourcePath = bobServerUri + '/shared-with-alice.txt' + return auth.initUserFromResponse(auth.currentClient) .then(webId => { expect(webId).to.equal(aliceWebId) - let idToken = auth.idToken - - return fetch(bobServerUri + '/shared-with-alice.txt', { + return auth.issuePoPTokenFor(bobServerUri, auth.session) + }) + .then(popToken => { + return fetch(protectedResourcePath, { headers: { - 'Authorization': 'Bearer ' + idToken + 'Authorization': 'Bearer ' + popToken } }) }) diff --git a/test/integration/errors-oidc-test.js b/test/integration/errors-oidc-test.js index 2abde6e34..bd6445b89 100644 --- a/test/integration/errors-oidc-test.js +++ b/test/integration/errors-oidc-test.js @@ -87,7 +87,7 @@ describe('OIDC error handling', function () { it('should return a 401 error', () => { return server.get('/profile/') .set('Authorization', 'Bearer ' + expiredToken) - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is expired."') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is expired"') .expect(401) }) }) diff --git a/test/resources/accounts-acl/db/oidc/op/provider.json b/test/resources/accounts-acl/db/oidc/op/provider.json index 88504f9ec..50d51fbf5 100644 --- a/test/resources/accounts-acl/db/oidc/op/provider.json +++ b/test/resources/accounts-acl/db/oidc/op/provider.json @@ -43,10 +43,10 @@ "claim_types_supported": [ "normal" ], - "claims_supported": "", + "claims_supported": [], "claims_parameter_supported": false, - "request_parameter_supported": false, - "request_uri_parameter_supported": true, + "request_parameter_supported": true, + "request_uri_parameter_supported": false, "require_request_uri_registration": false, "check_session_iframe": "https://localhost:7777/session", "end_session_endpoint": "https://localhost:7777/logout", diff --git a/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json index 59a68b506..1443bcc7a 100644 --- a/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json +++ b/test/resources/accounts-acl/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7777.json @@ -1 +1 @@ -{"provider":{"url":"https://localhost:7777","configuration":{"issuer":"https://localhost:7777","authorization_endpoint":"https://localhost:7777/authorize","token_endpoint":"https://localhost:7777/token","userinfo_endpoint":"https://localhost:7777/userinfo","jwks_uri":"https://localhost:7777/jwks","registration_endpoint":"https://localhost:7777/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7777/session","end_session_endpoint":"https://localhost:7777/logout"},"jwks":{"keys":[{"kid":"ohtdSKLCdYs","kty":"RSA","alg":"RS256","n":"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"gI5JLLhVFG8","kty":"RSA","alg":"RS384","n":"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1ZHTLTyLbQs","kty":"RSA","alg":"RS512","n":"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"AVS5efNiEEM","kty":"RSA","alg":"RS256","n":"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"ZVFUPkFyy18","kty":"RSA","alg":"RS384","n":"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"mROehy7CZO4","kty":"RSA","alg":"RS512","n":"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"it1Z6EDEV5g","kty":"RSA","alg":"RS256","n":"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"7f1be9aa47bb1372b3bc75e91da352b4","client_secret":"c48b13e77bd58f8d7208f34cde22e898","redirect_uris":["https://localhost:7777/api/oidc/rp/https%3A%2F%2Flocalhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7777/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJhdWQiOiI3ZjFiZTlhYTQ3YmIxMzcyYjNiYzc1ZTkxZGEzNTJiNCJ9.DNCfFeM-NyvWuZHQNJlVl8gFJaRh0vOZgoUX-88sGeFR0k9KS9poySBX8hNuZ3Lrnx-_A98dH1HbVijXHSC8pn4y1Lzmh-cnM-p8u5NWGxNuZt1uLHj8hdNJW7iY4cIFvCfKq3-eblDVbyTDfIJBGPq5x0kVZ2GC1M6Qo4mufNGiHncZ_QiZDW4l9VRM6mzZ0exoiHU00YwIUaa9rGepOefPuoEqOCE7RIxUrdc3Mwa_qgyDbJj3XO58r9JHMQYP9mcweTvLV9mth-B-Azo0kp4pC4TZSEb-5VPRnDgQME-boxDJIbsNP4LfgNSWqHhp5ZLuz2AzJJVsZH8-qbGPkA","registration_client_uri":"https://localhost:7777/register/7f1be9aa47bb1372b3bc75e91da352b4","client_id_issued_at":1491941281,"client_secret_expires_at":0}} \ No newline at end of file +{"provider":{"url":"https://localhost:7777","configuration":{"issuer":"https://localhost:7777","authorization_endpoint":"https://localhost:7777/authorize","token_endpoint":"https://localhost:7777/token","userinfo_endpoint":"https://localhost:7777/userinfo","jwks_uri":"https://localhost:7777/jwks","registration_endpoint":"https://localhost:7777/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7777/session","end_session_endpoint":"https://localhost:7777/logout"},"jwks":{"keys":[{"kid":"ohtdSKLCdYs","kty":"RSA","alg":"RS256","n":"sxljNM34KhyDZIXX6mjR0GIbs8Z_IzeBfoFDlkxhdf2Tigl_mCZEnc88fBp619e4l3D_t5GfyR0ZWQuhmCUTY8AJuKqdyuV_jU59nvut_izKydgNxBHGeFMd9abG-PTuq6iE3qEyr8A04KAsZZh2Zact5i6Xvb6N1GB4HDMU3LUAcUwkB6QhCpC4BPzwrTQ8DJZEz1O-_cZj9Y60gFvEo1NCLY6ZppYCfI5wqQhaQJ3jsG0TM03w4w2mcWALrIRoCrt-FIVqKHlKaeiioQALlj3Hdv38hljZtO7FykPqZE4N0nn7T1KQyj2LNCYDU_-ibTwdWm9yagdGuEWPCGvVnw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"gI5JLLhVFG8","kty":"RSA","alg":"RS384","n":"1LvKSpE6v2nhiIdErilavuIRu7aFc3Sej72jtDYAZze6R4dl3-nNnuaNBj8dD3PU16ZD8HQrLKTV6W77udl2yAjar-ZcVpItf7VUX_dCQsRehe7LVC_NgiBVFz88JI_rFF3F2WLC4rIXujv5XdG2v7UyV-KAODrPgY5-jfDJOv11_Klrrpekrtlk98STu71HJYTQR9CzQnMtxBVCOXOIVPVaexnV6gKBSrRtgKHqJxt8FRU3j6xYBwAdeDZaUyeHyUAz2oZkEHmNxoxEj-6yqaTf53AEf3EKbzYHCr4puRJx3H05ZLHkRoUG8utl7CxDsSQPDbwnk2jPufFSmPvR2Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1ZHTLTyLbQs","kty":"RSA","alg":"RS512","n":"uXwK4QaRFmscFO4Sa5nKr5PwL2mWBL9e-omB2cCqqB2V6e7VHq5A_ybEFKXcXDGJKxxc2fHo_PNclUAqIr9Qa98nkQt0bd_F2QxtCqPc-3WcUoe3s3TIVNIOWwp93OAlabBkuNfb7dxnpUjYeGzIs-G7EPhON_5x0h2sC0r3v3Ev_J1mwrR3z9tpUzaODqmI2LKdc3Tu9Ha09CWzb4uRTXC4eVIJoEMxOelvxn6l8CMWLuv2XPaw-pMv33WK4QKfmnwJWO5TLvF2SaYR31oEL3GeG-SwIFTek1xX3cdeNljqsYCzHGHd4PxSqJGI3BPqn55FPCbdx46ZMmlOz_ImFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"AVS5efNiEEM","kty":"RSA","alg":"RS256","n":"tQxTzwMoSCCRkiNUyp7CABfMZn8LU56axO31ErnW9qDZ4uuPdSO96nqHBU2JoMbnUjFQ9hufAt3UJPHDDD8kNoOOgEZb-CWnb_349oHb8bn7aIOpX1peukndSJ6Nt8SBvbARkb4ErI2b7V9588R8kPwVdW65BAK4ub1lc4EewKJWv4nVIvtp9m_qlohV321rru573hS3BI5qOX2NY1m_Abz4sBGqJVR1o95MqR2IYUeCSORPj34GSdHNUipMVJrouI7LAoO9dNhCu1q8Efy-Sn1YuCgEyTy_AMDuVgBgf1AHssXRymbE6A_IKys2ZxYZPYAUZflyffdUX9qmhtACaQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"ZVFUPkFyy18","kty":"RSA","alg":"RS384","n":"q1VCGGAL3arQ5tG9vMefKaHC9EXJLlJYu7Lgk_8RBPKJ8yejiTkU7xRWwJowK9kLsyYTHvCsJlGc-phNyEAE58QoqmQGePbr80H_q-7fF3H85UsQ5XFg2A06KQYT3dLn57Qzsf-qlJKwrVR3Rrz1XoxYY-IgEHPad86xW5PlwPKiNY1ZaWsOjdeBccsgfCeG5tn13a3GY5BoX90w7b8ly_BsL904-_Yeog_deesQ39oE_XKpORBnDxvUjFtPJIpaPMCHEqAKiVXH_dZrcGUevs9xvDl2Odiku7pUam3atzNQENKwB9HMDjATVdYZfelF9cllDVAUXKFHMenwqoV7PQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"mROehy7CZO4","kty":"RSA","alg":"RS512","n":"xq5YLUgQU82zwNtjw6xTbkAxpTp56_lnLy33srWPlZeFypbT8p2hC-918Vn08j-NuvzUeqarFLv7xBUZWrV2ilho_IYQWBZdMYCraBtDoLglctJtb6RzRG7rF0KsiyxsTwLCZ5UwcGpc_ZIrcDTRkmHvgqfA-KKpK_hIAwGC7rwNPDK0E26vuiaH85wnanQaWfdHzHFPW-cUWFbmjOZIQh0XHQSPnjE2JYX7rWWKga_8Oq7CUF_ArEF-8qTGL59GwS4OFcilvwyb53ANHy2bOEidRZCGQo6Kh2EjyjBHNB_YAiOLwfeTstb0fbDfWbfmdO3lW_-lBuGnQMPY5ukG5w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"it1Z6EDEV5g","kty":"RSA","alg":"RS256","n":"xZgfW761Mad1PHSOINv6kU13aiueEn19Ko3CR5EiuyuO5v8uJMfV24Mg8JTOxq9GuLIzte4CMg-5kFxQVopkqYZ8TP3eHAW6kWbh4j-C3I8vUJiA6LWGblFUsMg_sWvjwMPK4oF2bNeqGSVXSOtg3PMuBdV5wB4IBDikTovgSSbQ2gfgkEil94jOh0_bjzDXMDH6dv8Ong2Fj_bfWUg3MKcm6yVTCwCfqlfgNpcEqm6m3SVVpQRVxvlbsPmBt61w3QgOwC68rTD1BRVsH_DyL8DVsQKsg3PAbKqqNY0HM5YS9VPdFFxKYHiX7hZuNmtcPWDStxWSQNrvn4aaw5Xi5w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"7f1be9aa47bb1372b3bc75e91da352b4","client_secret":"c48b13e77bd58f8d7208f34cde22e898","redirect_uris":["https://localhost:7777/api/oidc/rp/https%3A%2F%2Flocalhost%3A7777"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7777","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7777/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3Nzc3Iiwic3ViIjoiN2YxYmU5YWE0N2JiMTM3MmIzYmM3NWU5MWRhMzUyYjQiLCJhdWQiOiI3ZjFiZTlhYTQ3YmIxMzcyYjNiYzc1ZTkxZGEzNTJiNCJ9.DNCfFeM-NyvWuZHQNJlVl8gFJaRh0vOZgoUX-88sGeFR0k9KS9poySBX8hNuZ3Lrnx-_A98dH1HbVijXHSC8pn4y1Lzmh-cnM-p8u5NWGxNuZt1uLHj8hdNJW7iY4cIFvCfKq3-eblDVbyTDfIJBGPq5x0kVZ2GC1M6Qo4mufNGiHncZ_QiZDW4l9VRM6mzZ0exoiHU00YwIUaa9rGepOefPuoEqOCE7RIxUrdc3Mwa_qgyDbJj3XO58r9JHMQYP9mcweTvLV9mth-B-Azo0kp4pC4TZSEb-5VPRnDgQME-boxDJIbsNP4LfgNSWqHhp5ZLuz2AzJJVsZH8-qbGPkA","registration_client_uri":"https://localhost:7777/register/7f1be9aa47bb1372b3bc75e91da352b4","client_id_issued_at":1491941281,"client_secret_expires_at":0}} diff --git a/test/resources/accounts-scenario/alice/db/oidc/op/provider.json b/test/resources/accounts-scenario/alice/db/oidc/op/provider.json index e6cdabd8b..1a9a5a909 100644 --- a/test/resources/accounts-scenario/alice/db/oidc/op/provider.json +++ b/test/resources/accounts-scenario/alice/db/oidc/op/provider.json @@ -43,10 +43,10 @@ "claim_types_supported": [ "normal" ], - "claims_supported": "", + "claims_supported": [], "claims_parameter_supported": false, - "request_parameter_supported": false, - "request_uri_parameter_supported": true, + "request_parameter_supported": true, + "request_uri_parameter_supported": false, "require_request_uri_registration": false, "check_session_iframe": "https://localhost:7000/session", "end_session_endpoint": "https://localhost:7000/logout", diff --git a/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json b/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json index f0110b7cd..64bca5b70 100644 --- a/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json +++ b/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json @@ -1 +1 @@ -{"provider":{"url":"https://localhost:7000","configuration":{"issuer":"https://localhost:7000","authorization_endpoint":"https://localhost:7000/authorize","token_endpoint":"https://localhost:7000/token","userinfo_endpoint":"https://localhost:7000/userinfo","jwks_uri":"https://localhost:7000/jwks","registration_endpoint":"https://localhost:7000/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7000/session","end_session_endpoint":"https://localhost:7000/logout"},"jwks":{"keys":[{"kid":"exSw1tC0jPw","kty":"RSA","alg":"RS256","n":"stiawfAYMau0L6VtUt2DCt9ytp0JnpjBlf8oujcPJsZ7IGNl4cq9VDEkm6WKxiaQ5aHwjrIF4EtW97Q1LwUIloiLgYvgBj6ADV1Zfa7-KDIoSE1nH1Uz8NWbPwaJ4dsjDQUa8EOGPAHjw1zgmCnOd70lIvqM8MnNjg9haut3tUhrILOmo3ubExawkvtp7GdiUqwSGo5K7s1WcKP4nQgd8SNxVMBFAyWC380_ZXcPL9SKgDsw9DIExmMVDjmaPn4orF3zivqVfU0VHi7z6ObNnBia2U6FK-M-j1-nPVNXW2En2xrtJ-nnGoAzasQ__GkC0XpYLyjv_4kuGkEFUwN1Bw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"XHWy74gIj2o","kty":"RSA","alg":"RS384","n":"rPDDwDbxtk6wV4cVi5jhTDMyP6MisKZypSm6-JQ1sMGjY2TcwVAMugIsDdY6hpcWvfGR8uJymCmnNvHrYOKsMqCEmexXoGBg-gqsuitjzxQUQfmulcD5MGrbsuGVpmuPKQ9lkT0BjdTplKtrKvBqIrdWCIp5wivh0NxI3tqb7eEzMc1rJQ781SKlQAxM5BLghLoZpdUiyHl1sKYH5ofs7Qqn-MBagFMtmy8Fl0YrnX2CSKM6xwGOlqm6dbVGpLiOdBLzfL-9ICyg1zurxWOUSIKosBY_dNUdx3e9QdsbHD74kKCEYe-BEvgj8t_dnEST_8g4hmxEeevOdSuAkDE-eQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1pWK9Xv5qtw","kty":"RSA","alg":"RS512","n":"2Pxvhef0LSwCNFjBnBnTeRnN_kc1G_frzLCTqyPMow8jICVmK_-44QlOi860J12rnSGYi-UWOtg5ZRTnNCAakMnXtqajjPQ4PxmcMkrkdCfhyShYMjmqTICGUPfOujX3d_oc3l-SSpBeQdpSejecaoyIAmR4Ra7x37PWiZgw2b3Ss-TMeL8iufc6221gNDAzmOlQmVby0SXz43Jf1WbUnRLBygAGmcD18CSawNSQL2lZMRtaFlTikZ5Nz9dbzUS5U8btg99u9cOL1wL6xLnMX2MdYImF_ThtDdFW-Q3_Xj8xYJIUinMKSyPofk0yOD5F0OcjR2IIp828BO42htb8lQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hE56feUj3HU","kty":"RSA","alg":"RS256","n":"5UAby9SJp2vDnV8ZIq7E5HHtGYKbAVwTmzYSxbdcMBhJScoY2HX2-N8cqZNIf6RhE7ipimVkbYeXXX795DtnbCN9Jcl8iKbWBLDe6ozHyQ-ZEuzdWe8gSi6HGwCW3ECfN8dXUbS72BIvID1KAe2LoQQuyRx1A9nlHQCJao31w7-y17h-j13_X5YhmVBYmLwmQI-3yOI4AYGFgwEuuS347X6bDk4IoSSLVieM65SAL9djs_ZzIyXrV5BEf7eY-zCazRt7vdqn11W_aM-JdyS5xDrsgwVPhaksU50vgPOjfzbOLVALvEDQ-sxCuT1Ic6S3I9zrVq6SzORW7vZtiKn_YQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"TcucFsr7B9c","kty":"RSA","alg":"RS384","n":"yNViaNYveaDftUVYRQmi1JbzBBk-uvOeQ-4vr_levpAMCVFrtEWN5A6jWmhbD4B3nvAn9828cjt1697nNPOIbF2hzRWCZIfsN5YJUbhseREb05ZL5TLlv5TkHj3sdhpmQqcd6JWcCQDIbaiZeLdQ-Ljm5dbckZlsJc1eJ96mlXVlQ3VaLbrEJThXjJ_YtPfMq1vUAzHpq-OF4yhGoTzvcVEswiH0tyTDobmaQuGJq1DabTC0-Vt4TpmlxOHLgCU5-ofehHaIeLqwRUrl6n5gKo0CX-7a8qvGYNX14X0Iq_1CjhP1Q8619wcFfXESFgitl7EQrncfCx8TrtdOIuFGeQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"7cB0RYQoGVA","kty":"RSA","alg":"RS512","n":"s75H3KlbQgGFLGf_oqYC-cv0-iQ6iRi4bDs1x00taHeTJQPazYJny-plYxi3OU7U9kCChS3v2zIIiAb5IOHI9nxTtfra2p1-VNIEe8YLqpJsYbie5uXSGXNahHIsZNjYO0kdTg-WkUZOR2jyeSUPOggp2zNBM_9UUUhLWWVKE_SHshm4vbHJIxIZfDmwLhZEUwvgwO1-b-VAitNd4kQXfbg2KSxXPb7_pRK9qV2KJJJ4k4K2oa7tFfilXwB1FDZnPgPLxI7dmzwgwekngXJ5PfQrVvsUDBe9mZUH2wanZ5q3W9qF7yLQYbMi8l9O8CQYHLstSNNMDc4okYZQY-HCcQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"_KGGowgYPwQ","kty":"RSA","alg":"RS256","n":"pp5I4Ubud6110-hIvfFsosJLSn-OrrW1C2ck5751GydxikI6sQnMlqbAS1yjyZSWRYPKWR8vD5NRp-EKP2Hd0dS1hA9_hNeQ4JKCcvmlOpmy07ckpr4fg6G-l501-36u2pnH5lJJGvA84xlaEfcqH3urHhsPbrZaurCOhiBPON6ek2GF_H1sYvdzflQ0E0k5ibwHNdVE85Ou8Uvzw58eDl0uhlwpRPg_k_zQFyeNK8MyDTcnExR13xU4IcnQPz3VdjC6BnOZWDE_GmspCE_4apd3bSFEHcV9C4v1PCLqQurBXTs0vgvfWML9UnSqWoGlnkczpYGgtujnnsxRpWFmCQ","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"2d7c299a1aa8e8cadb6a0bb93b6e7873","client_secret":"b2926a0f21cec49c906b7b7956cc44ce","redirect_uris":["https://localhost:7000/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7000/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAwIiwic3ViIjoiMmQ3YzI5OWExYWE4ZThjYWRiNmEwYmI5M2I2ZTc4NzMiLCJhdWQiOiIyZDdjMjk5YTFhYThlOGNhZGI2YTBiYjkzYjZlNzg3MyJ9.FL7GfVjf1faSrKg6G7EmQyGFpprHf-Djw06kLypEu9__g2ozzSgxPzo2cgHWGc5gNQ9D5FU-unwZmx354WvIk0DvU4GF_sDhG5gfVgRUiwNgKzgyaxl87aoUG4jYfwHDYwvZLXCPIuoCD7iB2u4cD_NYhK2u6OQST9bRSTlelrXN0MyJbDy1eItY6ys8yH0Yw-584SK6ksZh2NmjvBr73znmVI0xHdv80ntcrfagw-G1PK79OG_DH_wjPqoUI9yUxpY2AjnLkqbraQIwT6Uwx0eFNCj7OwVVoIOkxdDMCargpSHF1jvBBL8wsXqppuEy0YhHYIfU6POFZBofRJrKtQ","registration_client_uri":"https://localhost:7000/register/2d7c299a1aa8e8cadb6a0bb93b6e7873","client_id_issued_at":1489773557,"client_secret_expires_at":0}} \ No newline at end of file +{"provider":{"url":"https://localhost:7000","configuration":{"issuer":"https://localhost:7000","authorization_endpoint":"https://localhost:7000/authorize","token_endpoint":"https://localhost:7000/token","userinfo_endpoint":"https://localhost:7000/userinfo","jwks_uri":"https://localhost:7000/jwks","registration_endpoint":"https://localhost:7000/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7000/session","end_session_endpoint":"https://localhost:7000/logout"},"jwks":{"keys":[{"kid":"exSw1tC0jPw","kty":"RSA","alg":"RS256","n":"stiawfAYMau0L6VtUt2DCt9ytp0JnpjBlf8oujcPJsZ7IGNl4cq9VDEkm6WKxiaQ5aHwjrIF4EtW97Q1LwUIloiLgYvgBj6ADV1Zfa7-KDIoSE1nH1Uz8NWbPwaJ4dsjDQUa8EOGPAHjw1zgmCnOd70lIvqM8MnNjg9haut3tUhrILOmo3ubExawkvtp7GdiUqwSGo5K7s1WcKP4nQgd8SNxVMBFAyWC380_ZXcPL9SKgDsw9DIExmMVDjmaPn4orF3zivqVfU0VHi7z6ObNnBia2U6FK-M-j1-nPVNXW2En2xrtJ-nnGoAzasQ__GkC0XpYLyjv_4kuGkEFUwN1Bw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"XHWy74gIj2o","kty":"RSA","alg":"RS384","n":"rPDDwDbxtk6wV4cVi5jhTDMyP6MisKZypSm6-JQ1sMGjY2TcwVAMugIsDdY6hpcWvfGR8uJymCmnNvHrYOKsMqCEmexXoGBg-gqsuitjzxQUQfmulcD5MGrbsuGVpmuPKQ9lkT0BjdTplKtrKvBqIrdWCIp5wivh0NxI3tqb7eEzMc1rJQ781SKlQAxM5BLghLoZpdUiyHl1sKYH5ofs7Qqn-MBagFMtmy8Fl0YrnX2CSKM6xwGOlqm6dbVGpLiOdBLzfL-9ICyg1zurxWOUSIKosBY_dNUdx3e9QdsbHD74kKCEYe-BEvgj8t_dnEST_8g4hmxEeevOdSuAkDE-eQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1pWK9Xv5qtw","kty":"RSA","alg":"RS512","n":"2Pxvhef0LSwCNFjBnBnTeRnN_kc1G_frzLCTqyPMow8jICVmK_-44QlOi860J12rnSGYi-UWOtg5ZRTnNCAakMnXtqajjPQ4PxmcMkrkdCfhyShYMjmqTICGUPfOujX3d_oc3l-SSpBeQdpSejecaoyIAmR4Ra7x37PWiZgw2b3Ss-TMeL8iufc6221gNDAzmOlQmVby0SXz43Jf1WbUnRLBygAGmcD18CSawNSQL2lZMRtaFlTikZ5Nz9dbzUS5U8btg99u9cOL1wL6xLnMX2MdYImF_ThtDdFW-Q3_Xj8xYJIUinMKSyPofk0yOD5F0OcjR2IIp828BO42htb8lQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hE56feUj3HU","kty":"RSA","alg":"RS256","n":"5UAby9SJp2vDnV8ZIq7E5HHtGYKbAVwTmzYSxbdcMBhJScoY2HX2-N8cqZNIf6RhE7ipimVkbYeXXX795DtnbCN9Jcl8iKbWBLDe6ozHyQ-ZEuzdWe8gSi6HGwCW3ECfN8dXUbS72BIvID1KAe2LoQQuyRx1A9nlHQCJao31w7-y17h-j13_X5YhmVBYmLwmQI-3yOI4AYGFgwEuuS347X6bDk4IoSSLVieM65SAL9djs_ZzIyXrV5BEf7eY-zCazRt7vdqn11W_aM-JdyS5xDrsgwVPhaksU50vgPOjfzbOLVALvEDQ-sxCuT1Ic6S3I9zrVq6SzORW7vZtiKn_YQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"TcucFsr7B9c","kty":"RSA","alg":"RS384","n":"yNViaNYveaDftUVYRQmi1JbzBBk-uvOeQ-4vr_levpAMCVFrtEWN5A6jWmhbD4B3nvAn9828cjt1697nNPOIbF2hzRWCZIfsN5YJUbhseREb05ZL5TLlv5TkHj3sdhpmQqcd6JWcCQDIbaiZeLdQ-Ljm5dbckZlsJc1eJ96mlXVlQ3VaLbrEJThXjJ_YtPfMq1vUAzHpq-OF4yhGoTzvcVEswiH0tyTDobmaQuGJq1DabTC0-Vt4TpmlxOHLgCU5-ofehHaIeLqwRUrl6n5gKo0CX-7a8qvGYNX14X0Iq_1CjhP1Q8619wcFfXESFgitl7EQrncfCx8TrtdOIuFGeQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"7cB0RYQoGVA","kty":"RSA","alg":"RS512","n":"s75H3KlbQgGFLGf_oqYC-cv0-iQ6iRi4bDs1x00taHeTJQPazYJny-plYxi3OU7U9kCChS3v2zIIiAb5IOHI9nxTtfra2p1-VNIEe8YLqpJsYbie5uXSGXNahHIsZNjYO0kdTg-WkUZOR2jyeSUPOggp2zNBM_9UUUhLWWVKE_SHshm4vbHJIxIZfDmwLhZEUwvgwO1-b-VAitNd4kQXfbg2KSxXPb7_pRK9qV2KJJJ4k4K2oa7tFfilXwB1FDZnPgPLxI7dmzwgwekngXJ5PfQrVvsUDBe9mZUH2wanZ5q3W9qF7yLQYbMi8l9O8CQYHLstSNNMDc4okYZQY-HCcQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"_KGGowgYPwQ","kty":"RSA","alg":"RS256","n":"pp5I4Ubud6110-hIvfFsosJLSn-OrrW1C2ck5751GydxikI6sQnMlqbAS1yjyZSWRYPKWR8vD5NRp-EKP2Hd0dS1hA9_hNeQ4JKCcvmlOpmy07ckpr4fg6G-l501-36u2pnH5lJJGvA84xlaEfcqH3urHhsPbrZaurCOhiBPON6ek2GF_H1sYvdzflQ0E0k5ibwHNdVE85Ou8Uvzw58eDl0uhlwpRPg_k_zQFyeNK8MyDTcnExR13xU4IcnQPz3VdjC6BnOZWDE_GmspCE_4apd3bSFEHcV9C4v1PCLqQurBXTs0vgvfWML9UnSqWoGlnkczpYGgtujnnsxRpWFmCQ","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"2d7c299a1aa8e8cadb6a0bb93b6e7873","client_secret":"b2926a0f21cec49c906b7b7956cc44ce","redirect_uris":["https://localhost:7000/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7000/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAwIiwic3ViIjoiMmQ3YzI5OWExYWE4ZThjYWRiNmEwYmI5M2I2ZTc4NzMiLCJhdWQiOiIyZDdjMjk5YTFhYThlOGNhZGI2YTBiYjkzYjZlNzg3MyJ9.FL7GfVjf1faSrKg6G7EmQyGFpprHf-Djw06kLypEu9__g2ozzSgxPzo2cgHWGc5gNQ9D5FU-unwZmx354WvIk0DvU4GF_sDhG5gfVgRUiwNgKzgyaxl87aoUG4jYfwHDYwvZLXCPIuoCD7iB2u4cD_NYhK2u6OQST9bRSTlelrXN0MyJbDy1eItY6ys8yH0Yw-584SK6ksZh2NmjvBr73znmVI0xHdv80ntcrfagw-G1PK79OG_DH_wjPqoUI9yUxpY2AjnLkqbraQIwT6Uwx0eFNCj7OwVVoIOkxdDMCargpSHF1jvBBL8wsXqppuEy0YhHYIfU6POFZBofRJrKtQ","registration_client_uri":"https://localhost:7000/register/2d7c299a1aa8e8cadb6a0bb93b6e7873","client_id_issued_at":1489773557,"client_secret_expires_at":0}} diff --git a/test/resources/accounts-scenario/bob/db/oidc/op/provider.json b/test/resources/accounts-scenario/bob/db/oidc/op/provider.json index 4f61349e0..424ccea1b 100644 --- a/test/resources/accounts-scenario/bob/db/oidc/op/provider.json +++ b/test/resources/accounts-scenario/bob/db/oidc/op/provider.json @@ -43,10 +43,10 @@ "claim_types_supported": [ "normal" ], - "claims_supported": "", + "claims_supported": [], "claims_parameter_supported": false, - "request_parameter_supported": false, - "request_uri_parameter_supported": true, + "request_parameter_supported": true, + "request_uri_parameter_supported": false, "require_request_uri_registration": false, "check_session_iframe": "https://localhost:7001/session", "end_session_endpoint": "https://localhost:7001/logout", diff --git a/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json index eb53eeafd..0df32c0de 100644 --- a/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json +++ b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json @@ -1 +1 @@ -{"provider":{"url":"https://localhost:7000","configuration":{"issuer":"https://localhost:7000","authorization_endpoint":"https://localhost:7000/authorize","token_endpoint":"https://localhost:7000/token","userinfo_endpoint":"https://localhost:7000/userinfo","jwks_uri":"https://localhost:7000/jwks","registration_endpoint":"https://localhost:7000/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7000/session","end_session_endpoint":"https://localhost:7000/logout"},"jwks":{"keys":[{"kid":"exSw1tC0jPw","kty":"RSA","alg":"RS256","n":"stiawfAYMau0L6VtUt2DCt9ytp0JnpjBlf8oujcPJsZ7IGNl4cq9VDEkm6WKxiaQ5aHwjrIF4EtW97Q1LwUIloiLgYvgBj6ADV1Zfa7-KDIoSE1nH1Uz8NWbPwaJ4dsjDQUa8EOGPAHjw1zgmCnOd70lIvqM8MnNjg9haut3tUhrILOmo3ubExawkvtp7GdiUqwSGo5K7s1WcKP4nQgd8SNxVMBFAyWC380_ZXcPL9SKgDsw9DIExmMVDjmaPn4orF3zivqVfU0VHi7z6ObNnBia2U6FK-M-j1-nPVNXW2En2xrtJ-nnGoAzasQ__GkC0XpYLyjv_4kuGkEFUwN1Bw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"XHWy74gIj2o","kty":"RSA","alg":"RS384","n":"rPDDwDbxtk6wV4cVi5jhTDMyP6MisKZypSm6-JQ1sMGjY2TcwVAMugIsDdY6hpcWvfGR8uJymCmnNvHrYOKsMqCEmexXoGBg-gqsuitjzxQUQfmulcD5MGrbsuGVpmuPKQ9lkT0BjdTplKtrKvBqIrdWCIp5wivh0NxI3tqb7eEzMc1rJQ781SKlQAxM5BLghLoZpdUiyHl1sKYH5ofs7Qqn-MBagFMtmy8Fl0YrnX2CSKM6xwGOlqm6dbVGpLiOdBLzfL-9ICyg1zurxWOUSIKosBY_dNUdx3e9QdsbHD74kKCEYe-BEvgj8t_dnEST_8g4hmxEeevOdSuAkDE-eQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1pWK9Xv5qtw","kty":"RSA","alg":"RS512","n":"2Pxvhef0LSwCNFjBnBnTeRnN_kc1G_frzLCTqyPMow8jICVmK_-44QlOi860J12rnSGYi-UWOtg5ZRTnNCAakMnXtqajjPQ4PxmcMkrkdCfhyShYMjmqTICGUPfOujX3d_oc3l-SSpBeQdpSejecaoyIAmR4Ra7x37PWiZgw2b3Ss-TMeL8iufc6221gNDAzmOlQmVby0SXz43Jf1WbUnRLBygAGmcD18CSawNSQL2lZMRtaFlTikZ5Nz9dbzUS5U8btg99u9cOL1wL6xLnMX2MdYImF_ThtDdFW-Q3_Xj8xYJIUinMKSyPofk0yOD5F0OcjR2IIp828BO42htb8lQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hE56feUj3HU","kty":"RSA","alg":"RS256","n":"5UAby9SJp2vDnV8ZIq7E5HHtGYKbAVwTmzYSxbdcMBhJScoY2HX2-N8cqZNIf6RhE7ipimVkbYeXXX795DtnbCN9Jcl8iKbWBLDe6ozHyQ-ZEuzdWe8gSi6HGwCW3ECfN8dXUbS72BIvID1KAe2LoQQuyRx1A9nlHQCJao31w7-y17h-j13_X5YhmVBYmLwmQI-3yOI4AYGFgwEuuS347X6bDk4IoSSLVieM65SAL9djs_ZzIyXrV5BEf7eY-zCazRt7vdqn11W_aM-JdyS5xDrsgwVPhaksU50vgPOjfzbOLVALvEDQ-sxCuT1Ic6S3I9zrVq6SzORW7vZtiKn_YQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"TcucFsr7B9c","kty":"RSA","alg":"RS384","n":"yNViaNYveaDftUVYRQmi1JbzBBk-uvOeQ-4vr_levpAMCVFrtEWN5A6jWmhbD4B3nvAn9828cjt1697nNPOIbF2hzRWCZIfsN5YJUbhseREb05ZL5TLlv5TkHj3sdhpmQqcd6JWcCQDIbaiZeLdQ-Ljm5dbckZlsJc1eJ96mlXVlQ3VaLbrEJThXjJ_YtPfMq1vUAzHpq-OF4yhGoTzvcVEswiH0tyTDobmaQuGJq1DabTC0-Vt4TpmlxOHLgCU5-ofehHaIeLqwRUrl6n5gKo0CX-7a8qvGYNX14X0Iq_1CjhP1Q8619wcFfXESFgitl7EQrncfCx8TrtdOIuFGeQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"7cB0RYQoGVA","kty":"RSA","alg":"RS512","n":"s75H3KlbQgGFLGf_oqYC-cv0-iQ6iRi4bDs1x00taHeTJQPazYJny-plYxi3OU7U9kCChS3v2zIIiAb5IOHI9nxTtfra2p1-VNIEe8YLqpJsYbie5uXSGXNahHIsZNjYO0kdTg-WkUZOR2jyeSUPOggp2zNBM_9UUUhLWWVKE_SHshm4vbHJIxIZfDmwLhZEUwvgwO1-b-VAitNd4kQXfbg2KSxXPb7_pRK9qV2KJJJ4k4K2oa7tFfilXwB1FDZnPgPLxI7dmzwgwekngXJ5PfQrVvsUDBe9mZUH2wanZ5q3W9qF7yLQYbMi8l9O8CQYHLstSNNMDc4okYZQY-HCcQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"_KGGowgYPwQ","kty":"RSA","alg":"RS256","n":"pp5I4Ubud6110-hIvfFsosJLSn-OrrW1C2ck5751GydxikI6sQnMlqbAS1yjyZSWRYPKWR8vD5NRp-EKP2Hd0dS1hA9_hNeQ4JKCcvmlOpmy07ckpr4fg6G-l501-36u2pnH5lJJGvA84xlaEfcqH3urHhsPbrZaurCOhiBPON6ek2GF_H1sYvdzflQ0E0k5ibwHNdVE85Ou8Uvzw58eDl0uhlwpRPg_k_zQFyeNK8MyDTcnExR13xU4IcnQPz3VdjC6BnOZWDE_GmspCE_4apd3bSFEHcV9C4v1PCLqQurBXTs0vgvfWML9UnSqWoGlnkczpYGgtujnnsxRpWFmCQ","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"370f34992ebf2d00bc6b4a2bd3dd77fd","client_secret":"1fbd9aa5561f242e7f9b1f95910a722d","redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAwIiwic3ViIjoiMzcwZjM0OTkyZWJmMmQwMGJjNmI0YTJiZDNkZDc3ZmQiLCJhdWQiOiIzNzBmMzQ5OTJlYmYyZDAwYmM2YjRhMmJkM2RkNzdmZCJ9.HkMGXV33JfSf5VFF3eY2XdEnH1KkG911-1MSoAcHaUjkXRJCW1KB1l0ofMEqBeHb3mC4iC8xZ3TP4F5kz2fPDQvAZe3v-LNjO2N8vCEpnT3HhKQnRitsA2zx0V6_aiGCDSyTavXK27OmSYwNs50RZQeSBjy76hjsS_sHu7_W42UDVn-beMkKpOhHnHddrir75JcmkUh1YqYMgopClQkt-Y22kdAQ3of2l17_QVDSUxatUEUVDSj76p8MAkYxb2YTdwULb-9fhQoYsy9JJphf59Bn5L26MlFlL9OgBYZRwVE8zvlGdyxllcgs4nSQbziOuQmArfQV3L0r-m8zDZYykw","registration_client_uri":"https://localhost:7000/register/370f34992ebf2d00bc6b4a2bd3dd77fd","client_id_issued_at":1489773628,"client_secret_expires_at":0}} \ No newline at end of file +{"provider":{"url":"https://localhost:7000","configuration":{"issuer":"https://localhost:7000","authorization_endpoint":"https://localhost:7000/authorize","token_endpoint":"https://localhost:7000/token","userinfo_endpoint":"https://localhost:7000/userinfo","jwks_uri":"https://localhost:7000/jwks","registration_endpoint":"https://localhost:7000/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7000/session","end_session_endpoint":"https://localhost:7000/logout"},"jwks":{"keys":[{"kid":"exSw1tC0jPw","kty":"RSA","alg":"RS256","n":"stiawfAYMau0L6VtUt2DCt9ytp0JnpjBlf8oujcPJsZ7IGNl4cq9VDEkm6WKxiaQ5aHwjrIF4EtW97Q1LwUIloiLgYvgBj6ADV1Zfa7-KDIoSE1nH1Uz8NWbPwaJ4dsjDQUa8EOGPAHjw1zgmCnOd70lIvqM8MnNjg9haut3tUhrILOmo3ubExawkvtp7GdiUqwSGo5K7s1WcKP4nQgd8SNxVMBFAyWC380_ZXcPL9SKgDsw9DIExmMVDjmaPn4orF3zivqVfU0VHi7z6ObNnBia2U6FK-M-j1-nPVNXW2En2xrtJ-nnGoAzasQ__GkC0XpYLyjv_4kuGkEFUwN1Bw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"XHWy74gIj2o","kty":"RSA","alg":"RS384","n":"rPDDwDbxtk6wV4cVi5jhTDMyP6MisKZypSm6-JQ1sMGjY2TcwVAMugIsDdY6hpcWvfGR8uJymCmnNvHrYOKsMqCEmexXoGBg-gqsuitjzxQUQfmulcD5MGrbsuGVpmuPKQ9lkT0BjdTplKtrKvBqIrdWCIp5wivh0NxI3tqb7eEzMc1rJQ781SKlQAxM5BLghLoZpdUiyHl1sKYH5ofs7Qqn-MBagFMtmy8Fl0YrnX2CSKM6xwGOlqm6dbVGpLiOdBLzfL-9ICyg1zurxWOUSIKosBY_dNUdx3e9QdsbHD74kKCEYe-BEvgj8t_dnEST_8g4hmxEeevOdSuAkDE-eQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"1pWK9Xv5qtw","kty":"RSA","alg":"RS512","n":"2Pxvhef0LSwCNFjBnBnTeRnN_kc1G_frzLCTqyPMow8jICVmK_-44QlOi860J12rnSGYi-UWOtg5ZRTnNCAakMnXtqajjPQ4PxmcMkrkdCfhyShYMjmqTICGUPfOujX3d_oc3l-SSpBeQdpSejecaoyIAmR4Ra7x37PWiZgw2b3Ss-TMeL8iufc6221gNDAzmOlQmVby0SXz43Jf1WbUnRLBygAGmcD18CSawNSQL2lZMRtaFlTikZ5Nz9dbzUS5U8btg99u9cOL1wL6xLnMX2MdYImF_ThtDdFW-Q3_Xj8xYJIUinMKSyPofk0yOD5F0OcjR2IIp828BO42htb8lQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hE56feUj3HU","kty":"RSA","alg":"RS256","n":"5UAby9SJp2vDnV8ZIq7E5HHtGYKbAVwTmzYSxbdcMBhJScoY2HX2-N8cqZNIf6RhE7ipimVkbYeXXX795DtnbCN9Jcl8iKbWBLDe6ozHyQ-ZEuzdWe8gSi6HGwCW3ECfN8dXUbS72BIvID1KAe2LoQQuyRx1A9nlHQCJao31w7-y17h-j13_X5YhmVBYmLwmQI-3yOI4AYGFgwEuuS347X6bDk4IoSSLVieM65SAL9djs_ZzIyXrV5BEf7eY-zCazRt7vdqn11W_aM-JdyS5xDrsgwVPhaksU50vgPOjfzbOLVALvEDQ-sxCuT1Ic6S3I9zrVq6SzORW7vZtiKn_YQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"TcucFsr7B9c","kty":"RSA","alg":"RS384","n":"yNViaNYveaDftUVYRQmi1JbzBBk-uvOeQ-4vr_levpAMCVFrtEWN5A6jWmhbD4B3nvAn9828cjt1697nNPOIbF2hzRWCZIfsN5YJUbhseREb05ZL5TLlv5TkHj3sdhpmQqcd6JWcCQDIbaiZeLdQ-Ljm5dbckZlsJc1eJ96mlXVlQ3VaLbrEJThXjJ_YtPfMq1vUAzHpq-OF4yhGoTzvcVEswiH0tyTDobmaQuGJq1DabTC0-Vt4TpmlxOHLgCU5-ofehHaIeLqwRUrl6n5gKo0CX-7a8qvGYNX14X0Iq_1CjhP1Q8619wcFfXESFgitl7EQrncfCx8TrtdOIuFGeQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"7cB0RYQoGVA","kty":"RSA","alg":"RS512","n":"s75H3KlbQgGFLGf_oqYC-cv0-iQ6iRi4bDs1x00taHeTJQPazYJny-plYxi3OU7U9kCChS3v2zIIiAb5IOHI9nxTtfra2p1-VNIEe8YLqpJsYbie5uXSGXNahHIsZNjYO0kdTg-WkUZOR2jyeSUPOggp2zNBM_9UUUhLWWVKE_SHshm4vbHJIxIZfDmwLhZEUwvgwO1-b-VAitNd4kQXfbg2KSxXPb7_pRK9qV2KJJJ4k4K2oa7tFfilXwB1FDZnPgPLxI7dmzwgwekngXJ5PfQrVvsUDBe9mZUH2wanZ5q3W9qF7yLQYbMi8l9O8CQYHLstSNNMDc4okYZQY-HCcQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"_KGGowgYPwQ","kty":"RSA","alg":"RS256","n":"pp5I4Ubud6110-hIvfFsosJLSn-OrrW1C2ck5751GydxikI6sQnMlqbAS1yjyZSWRYPKWR8vD5NRp-EKP2Hd0dS1hA9_hNeQ4JKCcvmlOpmy07ckpr4fg6G-l501-36u2pnH5lJJGvA84xlaEfcqH3urHhsPbrZaurCOhiBPON6ek2GF_H1sYvdzflQ0E0k5ibwHNdVE85Ou8Uvzw58eDl0uhlwpRPg_k_zQFyeNK8MyDTcnExR13xU4IcnQPz3VdjC6BnOZWDE_GmspCE_4apd3bSFEHcV9C4v1PCLqQurBXTs0vgvfWML9UnSqWoGlnkczpYGgtujnnsxRpWFmCQ","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"370f34992ebf2d00bc6b4a2bd3dd77fd","client_secret":"1fbd9aa5561f242e7f9b1f95910a722d","redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAwIiwic3ViIjoiMzcwZjM0OTkyZWJmMmQwMGJjNmI0YTJiZDNkZDc3ZmQiLCJhdWQiOiIzNzBmMzQ5OTJlYmYyZDAwYmM2YjRhMmJkM2RkNzdmZCJ9.HkMGXV33JfSf5VFF3eY2XdEnH1KkG911-1MSoAcHaUjkXRJCW1KB1l0ofMEqBeHb3mC4iC8xZ3TP4F5kz2fPDQvAZe3v-LNjO2N8vCEpnT3HhKQnRitsA2zx0V6_aiGCDSyTavXK27OmSYwNs50RZQeSBjy76hjsS_sHu7_W42UDVn-beMkKpOhHnHddrir75JcmkUh1YqYMgopClQkt-Y22kdAQ3of2l17_QVDSUxatUEUVDSj76p8MAkYxb2YTdwULb-9fhQoYsy9JJphf59Bn5L26MlFlL9OgBYZRwVE8zvlGdyxllcgs4nSQbziOuQmArfQV3L0r-m8zDZYykw","registration_client_uri":"https://localhost:7000/register/370f34992ebf2d00bc6b4a2bd3dd77fd","client_id_issued_at":1489773628,"client_secret_expires_at":0}} diff --git a/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json index f786e230e..862d7c9f9 100644 --- a/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json +++ b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json @@ -1 +1 @@ -{"provider":{"url":"https://localhost:7001","configuration":{"issuer":"https://localhost:7001","authorization_endpoint":"https://localhost:7001/authorize","token_endpoint":"https://localhost:7001/token","userinfo_endpoint":"https://localhost:7001/userinfo","jwks_uri":"https://localhost:7001/jwks","registration_endpoint":"https://localhost:7001/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7001/session","end_session_endpoint":"https://localhost:7001/logout"},"jwks":{"keys":[{"kid":"ysNKuDh7-rk","kty":"RSA","alg":"RS256","n":"wvMeFsXkedSC_tnFgzvSHSYqoki9d95_l6Rm3hcwNknOkaycrrJketqeE4oSq_H4curUdPjUXYwu5e5LSoEZERLNElTXY10MUpu_he0DrhlsnWbBlzm6e3YuPr3MZlO_beQhpVtTnPTTeOZgOnUK9A44uqIzWoh7uaiU5uRi5JrZFtVpk2KGp49o68IXkSvhd0BkFaEBB4r-BSjpWwXKeu9Y1Tp2V7C5pKpXHZwOzI4LZru-QoTARlLKGsFPxTjK1E47N76dy1usoKLu6Xs0toaiXnxNUTLPk4ERg1kk93mvHkiIDsP-jVawJh-bhWLXQEEm7lbAV0IkcySqiJaKkw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"Y8dNW6a_V18","kty":"RSA","alg":"RS384","n":"xQNISCAVvlsB4VTHq9HQcDf3PxF7D9DvnTNYPtXAxTIXx5bXVX4WxJU2xSTkYtN0k-yAMXQed9MAYNKsNwD7NAO7RV7m6jCSIgD1FEu3V6iEeliMetL4CfIe_Vn7Rb37lSI-gKaNMwBVIcYoAy7xOXLxxpSFJ5t357HbJnd3p0cgvx13sfyz-WyxqMLWY5IdxktwS-tdxUmpsk6M2xbcJB97c4h4afrfxp68ZB4fznC23aos6QUm7DLhGOURJAdwQTebUre9J6Vy3BXfKNpXb62AGpzPLGDzt-c-kQ05ckEzo9ZZZVC6l-DfMryb5rLZKlMKTefzL12ricSRcltcZw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"BSILu2VUSq8","kty":"RSA","alg":"RS512","n":"2OyR9CUp2B3_XrC1rwx3CxvsGenGyyjj5i_BMUyi8biEAu7N3aZ7AxvaSVtYGeWCWDRmPE2XImoEDtLBdG3wlOroOlRvgGnd3hlajqswIRgy3dmmbVETNqqJJQefc5tRESsA3VHKz04H3trcibo-ycM5HRc3cGXdWExg2XQUxkmOXKVCUEBnMpeWGlAG-QUGjGP3DVZ0V6-ldQXH_lP1ftt5zTWusOp0iyrLbvX7eWduVlfGsIHYNi3cVJdAxbZXUMwOwyHn3HUrlCDi1tc8_x8-pq2SgQhTrJQVF3D8UExYV_k6cTQOXRqJgz7LcISYyWULm8FM2NYWGl12MCMqqQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"xuMN0hE4aNA","kty":"RSA","alg":"RS256","n":"xs-BOX2tAPab_6ftuKFJNqJJPAMf6NnGEt_KPEuQKlS6Eoqxd1Sl3V6y8mj7g4TTg7Yb0JT0GjUmKs61cJww6w4JIQepzAKb_LT-mrOjckWTDC4lUSYm8IX-tfFDUKhkYh-rOQz7rNQ13BKQ_MHKGY3_imzp5tRvevkbwHzGjHRVMPKzRFBm20O5_IOSCFLYp0dIi-zKK7gSpZFfMW6ZoAoZiOhBoRhNFs-XJ6UUcAifNmpxnCDM9KJBGv7YCVroYnyt7pz0xSrab72ZGPQQo5EqnjvckO1ACQuekJfOCQ0c2yVd48y-W_wTDvSn1ZKOdecTE0BbQg2P-h1HYN3RFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hrVDwDlmtBc","kty":"RSA","alg":"RS384","n":"na2HnmI9weG040vd5v8mC9RkfzKmil-GtZxUNtCndW3MV_55x5yBund_TSo_rDHrlKm_ZvVWhvkhHtteZ-V_Yv521zA_vVaFVwCGQ0-KXSRW6GtereabW835tb23nQWItRepT1SX4Z_7tpS-_anpVVwaKvUqEJcUptFfkGICP98yMnemGkAR-ejLVNSElh4u9FU6q8Y4wBuBv_VRtcFanUcsnSDWIjCL0YyKZ1Ow7FqvGjpglBHsfzeWFyX2Hn2JZvozWNMGGm77ietL7fsPfvfAilrHXXFNk0Oso8DtQnj6Ft1oXLUyZijSiTN7AubpdaylW7tjbkXf42ZmPadjvw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"5DeLhvjbXpU","kty":"RSA","alg":"RS512","n":"xH5VCmySFeekK1oYflMd6XWV9PsNP8JBUbwhR0Uq4ANRPVdhzFc1N8GInEl-XWgBU9CYtLhMB4CrgRiFgSQPU7AUmYfmmaZ4ScGQItpIHcL5TSELw5ncQTmv4TYTEksvvESm-ihRbN6Irhrm-_izjzXZd1yRlpZJL-e4L5CGlIl4s1_ZwhHoF79Nw0_ql4Awn4hJQiZzdJnaJ36ltSVfIN750Glyv9MGVATpwKSsEtIiDHw8szcLXv04wPdmwTcblhgrSrgbPTn4YHpjmq6I6iFJz3sJEAGT-XbB7PdEC3Snk9CC8iJzaF-DrRVbp2BIi4Vo51AC1NPgESDU8lSWmQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"5lqnxcDvwtY","kty":"RSA","alg":"RS256","n":"nSn6UV7vgCImW0PExOhWUOqtT4_SM1ZShwN-Ti-4sIfiRgaOw1_Wf4PAHkqQmTp8xiOZhDOfe2NTDGhP0VENkwILPs_kdHq-Pm-4Qq4tx9nSEKdjq1XlEP99wmtmMQOSBdenwzkKzkXMMSROOqs3iItablA2vFnVfjZUsEioDikn6sQIg7nwQT6Sf76w1wv5uYrVlc-nU6FPh_08-h5C_IL2QNpbRBHM1BKtZEH2njDnSKVNFzwuwDfnjRtKwOtAmOwxxO0xXZHlDZYYE4tAlbAX1anJj_mjWxoLDPwQKvZCMw_XPLY3jo5nsSGOX2bBCWsZsZcbs_Cg0t58DldC2w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"eafafc5103e5b15ba06c3bed7c5dc3df","client_secret":"5eac22a963328151a139206a35036b17","redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7001"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7001","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAxIiwic3ViIjoiZWFmYWZjNTEwM2U1YjE1YmEwNmMzYmVkN2M1ZGMzZGYiLCJhdWQiOiJlYWZhZmM1MTAzZTViMTViYTA2YzNiZWQ3YzVkYzNkZiJ9.atRg3STosJx0a9FV8cadbr0TpccgdTwqjsKQQYFv1hwDptgJr_WPYfp5mpUvNfr-Q7M4Ege5mONmw-JKa9rkqGzB5EOsEuJs1CfXZcFPjNZU10X_3iDnZik6eqYDVHmpwJrgVm3GIM3sN1VDqyksVUyr-Dd6vNbVnnHZOFHUpcZBSkvOb6bensDlgQ6TpZ-45TtPNgeHHjmhaL3t0xFeVABjUBLg_38yrxP_-Tylc7KMZVNCQCCDgGkgHBPyzf9KwHv8UU_MvHtQzYlV4_2u14iz9mLbMvbGMWv9akdGCfwZDldThbVSfmt_lz3dfRNivGGRnJNec9tdP2wT4mv4uQ","registration_client_uri":"https://localhost:7001/register/eafafc5103e5b15ba06c3bed7c5dc3df","client_id_issued_at":1489773546,"client_secret_expires_at":0}} \ No newline at end of file +{"provider":{"url":"https://localhost:7001","configuration":{"issuer":"https://localhost:7001","authorization_endpoint":"https://localhost:7001/authorize","token_endpoint":"https://localhost:7001/token","userinfo_endpoint":"https://localhost:7001/userinfo","jwks_uri":"https://localhost:7001/jwks","registration_endpoint":"https://localhost:7001/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7001/session","end_session_endpoint":"https://localhost:7001/logout"},"jwks":{"keys":[{"kid":"ysNKuDh7-rk","kty":"RSA","alg":"RS256","n":"wvMeFsXkedSC_tnFgzvSHSYqoki9d95_l6Rm3hcwNknOkaycrrJketqeE4oSq_H4curUdPjUXYwu5e5LSoEZERLNElTXY10MUpu_he0DrhlsnWbBlzm6e3YuPr3MZlO_beQhpVtTnPTTeOZgOnUK9A44uqIzWoh7uaiU5uRi5JrZFtVpk2KGp49o68IXkSvhd0BkFaEBB4r-BSjpWwXKeu9Y1Tp2V7C5pKpXHZwOzI4LZru-QoTARlLKGsFPxTjK1E47N76dy1usoKLu6Xs0toaiXnxNUTLPk4ERg1kk93mvHkiIDsP-jVawJh-bhWLXQEEm7lbAV0IkcySqiJaKkw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"Y8dNW6a_V18","kty":"RSA","alg":"RS384","n":"xQNISCAVvlsB4VTHq9HQcDf3PxF7D9DvnTNYPtXAxTIXx5bXVX4WxJU2xSTkYtN0k-yAMXQed9MAYNKsNwD7NAO7RV7m6jCSIgD1FEu3V6iEeliMetL4CfIe_Vn7Rb37lSI-gKaNMwBVIcYoAy7xOXLxxpSFJ5t357HbJnd3p0cgvx13sfyz-WyxqMLWY5IdxktwS-tdxUmpsk6M2xbcJB97c4h4afrfxp68ZB4fznC23aos6QUm7DLhGOURJAdwQTebUre9J6Vy3BXfKNpXb62AGpzPLGDzt-c-kQ05ckEzo9ZZZVC6l-DfMryb5rLZKlMKTefzL12ricSRcltcZw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"BSILu2VUSq8","kty":"RSA","alg":"RS512","n":"2OyR9CUp2B3_XrC1rwx3CxvsGenGyyjj5i_BMUyi8biEAu7N3aZ7AxvaSVtYGeWCWDRmPE2XImoEDtLBdG3wlOroOlRvgGnd3hlajqswIRgy3dmmbVETNqqJJQefc5tRESsA3VHKz04H3trcibo-ycM5HRc3cGXdWExg2XQUxkmOXKVCUEBnMpeWGlAG-QUGjGP3DVZ0V6-ldQXH_lP1ftt5zTWusOp0iyrLbvX7eWduVlfGsIHYNi3cVJdAxbZXUMwOwyHn3HUrlCDi1tc8_x8-pq2SgQhTrJQVF3D8UExYV_k6cTQOXRqJgz7LcISYyWULm8FM2NYWGl12MCMqqQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"xuMN0hE4aNA","kty":"RSA","alg":"RS256","n":"xs-BOX2tAPab_6ftuKFJNqJJPAMf6NnGEt_KPEuQKlS6Eoqxd1Sl3V6y8mj7g4TTg7Yb0JT0GjUmKs61cJww6w4JIQepzAKb_LT-mrOjckWTDC4lUSYm8IX-tfFDUKhkYh-rOQz7rNQ13BKQ_MHKGY3_imzp5tRvevkbwHzGjHRVMPKzRFBm20O5_IOSCFLYp0dIi-zKK7gSpZFfMW6ZoAoZiOhBoRhNFs-XJ6UUcAifNmpxnCDM9KJBGv7YCVroYnyt7pz0xSrab72ZGPQQo5EqnjvckO1ACQuekJfOCQ0c2yVd48y-W_wTDvSn1ZKOdecTE0BbQg2P-h1HYN3RFw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"hrVDwDlmtBc","kty":"RSA","alg":"RS384","n":"na2HnmI9weG040vd5v8mC9RkfzKmil-GtZxUNtCndW3MV_55x5yBund_TSo_rDHrlKm_ZvVWhvkhHtteZ-V_Yv521zA_vVaFVwCGQ0-KXSRW6GtereabW835tb23nQWItRepT1SX4Z_7tpS-_anpVVwaKvUqEJcUptFfkGICP98yMnemGkAR-ejLVNSElh4u9FU6q8Y4wBuBv_VRtcFanUcsnSDWIjCL0YyKZ1Ow7FqvGjpglBHsfzeWFyX2Hn2JZvozWNMGGm77ietL7fsPfvfAilrHXXFNk0Oso8DtQnj6Ft1oXLUyZijSiTN7AubpdaylW7tjbkXf42ZmPadjvw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"5DeLhvjbXpU","kty":"RSA","alg":"RS512","n":"xH5VCmySFeekK1oYflMd6XWV9PsNP8JBUbwhR0Uq4ANRPVdhzFc1N8GInEl-XWgBU9CYtLhMB4CrgRiFgSQPU7AUmYfmmaZ4ScGQItpIHcL5TSELw5ncQTmv4TYTEksvvESm-ihRbN6Irhrm-_izjzXZd1yRlpZJL-e4L5CGlIl4s1_ZwhHoF79Nw0_ql4Awn4hJQiZzdJnaJ36ltSVfIN750Glyv9MGVATpwKSsEtIiDHw8szcLXv04wPdmwTcblhgrSrgbPTn4YHpjmq6I6iFJz3sJEAGT-XbB7PdEC3Snk9CC8iJzaF-DrRVbp2BIi4Vo51AC1NPgESDU8lSWmQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"5lqnxcDvwtY","kty":"RSA","alg":"RS256","n":"nSn6UV7vgCImW0PExOhWUOqtT4_SM1ZShwN-Ti-4sIfiRgaOw1_Wf4PAHkqQmTp8xiOZhDOfe2NTDGhP0VENkwILPs_kdHq-Pm-4Qq4tx9nSEKdjq1XlEP99wmtmMQOSBdenwzkKzkXMMSROOqs3iItablA2vFnVfjZUsEioDikn6sQIg7nwQT6Sf76w1wv5uYrVlc-nU6FPh_08-h5C_IL2QNpbRBHM1BKtZEH2njDnSKVNFzwuwDfnjRtKwOtAmOwxxO0xXZHlDZYYE4tAlbAX1anJj_mjWxoLDPwQKvZCMw_XPLY3jo5nsSGOX2bBCWsZsZcbs_Cg0t58DldC2w","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"eafafc5103e5b15ba06c3bed7c5dc3df","client_secret":"5eac22a963328151a139206a35036b17","redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7001"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7001","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAxIiwic3ViIjoiZWFmYWZjNTEwM2U1YjE1YmEwNmMzYmVkN2M1ZGMzZGYiLCJhdWQiOiJlYWZhZmM1MTAzZTViMTViYTA2YzNiZWQ3YzVkYzNkZiJ9.atRg3STosJx0a9FV8cadbr0TpccgdTwqjsKQQYFv1hwDptgJr_WPYfp5mpUvNfr-Q7M4Ege5mONmw-JKa9rkqGzB5EOsEuJs1CfXZcFPjNZU10X_3iDnZik6eqYDVHmpwJrgVm3GIM3sN1VDqyksVUyr-Dd6vNbVnnHZOFHUpcZBSkvOb6bensDlgQ6TpZ-45TtPNgeHHjmhaL3t0xFeVABjUBLg_38yrxP_-Tylc7KMZVNCQCCDgGkgHBPyzf9KwHv8UU_MvHtQzYlV4_2u14iz9mLbMvbGMWv9akdGCfwZDldThbVSfmt_lz3dfRNivGGRnJNec9tdP2wT4mv4uQ","registration_client_uri":"https://localhost:7001/register/eafafc5103e5b15ba06c3bed7c5dc3df","client_id_issued_at":1489773546,"client_secret_expires_at":0}} diff --git a/test/resources/accounts/db/oidc/op/provider.json b/test/resources/accounts/db/oidc/op/provider.json index e14f25455..27f678946 100644 --- a/test/resources/accounts/db/oidc/op/provider.json +++ b/test/resources/accounts/db/oidc/op/provider.json @@ -43,10 +43,10 @@ "claim_types_supported": [ "normal" ], - "claims_supported": "", + "claims_supported": [], "claims_parameter_supported": false, - "request_parameter_supported": false, - "request_uri_parameter_supported": true, + "request_parameter_supported": true, + "request_uri_parameter_supported": false, "require_request_uri_registration": false, "check_session_iframe": "https://localhost:3457/session", "end_session_endpoint": "https://localhost:3457/logout", @@ -408,4 +408,4 @@ }, "jwkSet": "{\"keys\":[{\"kid\":\"lNZOB-DPE1k\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"uvih8HfZj7Wu5Y8knLHxRY6v7oHL2jXWD-B6hXCreYhwaG9EEUt6Rp94p8-JBug3ywo8C_9dNg0RtQLEttcIC_vhqqlJI3pZxpGKXuD9h7XK-PppFVvgnfIGADG0Z-WzbcGDxlefStohR31Hjw5U3ioG3VtXGAYbqlOHM1l2UgDMJwBD5qwFmPP8gp5E2WQKCsuLvxDuOrkAbSDjw2zaI3RRmbLzdj4QkGej8GXhBptgM9RwcKmnoXu0sUdlootmcdiEg74yQ9M6EshNMhiv4k_W0rl7RqVOEL2PsAdmdbF_iWL8a90rGYOEILBrlU6bBR2mTvjV_Hvq-ifFy1YAmQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"Y38YKDtydoE\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"tfgZKLjc8UMIblfAlVibJI_2uAxDNprn2VVLebS0sp6d1mtCXQkMYLlJ6e-7kavl8we391Ovnq5bRgpsFRq_LtRX9MpVlfioAUHwWPEG-R6vrQjgo4uynVhI3UEPHyNmZA5J4u34HNVTfAgmquomwwOmOv29ZNRxuYP1kVtscz1JeFPwg6LA7BxWrLc9ev4FQR6tjJKdo2kdLjAXR92odbCzJZ_jdYT3vIVCexMHxhoKnqCImkhfgKbGXcPHXWcelmuA2tzBaLut-Jjo0nJVQjRNDqy0Gyac0TptwFIxaiyHeTqugolUmEaJSfBSLszIRdlOTIGPJ7zdg5dJFK_Lxw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"WyMVv6BJ5Dk\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"5JDlpbm2TjSW1wpdUZc5NHOqVVrNH_GumoODK_mk-MqImaIRpdR9b1ZJrK6FrW7HIF2bXvebD7olmp9a1goqe-ILbL_ORmhzlhRtyhjWQ-UOZqK5yOXqXXGQXgmok6TN-s55A-h_g12A7Yk5Y5S8EVa9EA4Axwqvm-Q_AkH0yS1qJo6BXYXb1fx205ucx-Ccot2LEBfxv8M7NOFTa-_G-sNchiKQMRoLhbZtLbSK2R1jkqGciEiRSLeXNG4nDu7Wd91-vhBixA1McxnzW96mW8lQwNXXo4gNH7SjONtYLlPQhZVEbmsQmXrOQN8a5RDkybFOIsbucItizSE9V_D7WQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"UykSj_HLgFA\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"u79eQlGJN2XFNR-uEmPVtrB_ENRqaS81o6m63tZ5-PwhGHCwJ7rfVnnnvf6Ij_p91Z9pNpWBIVyZcw6UmQIoIBH-3BfxdaqhBxX9bf_N78TKj8_HU5IYjGijale4gog3kj9W2tJJO7R9iA43msjwLRD7pbAHp1iKFJgVTSXJlyLRbC82Dj4ivsEgJjPGvZt16OsGP5myIQwXEGzSPcEI0R9daZE5iM6xFZosaJ8B77eU-Aj3ciwxUBPi5BSZi2P1ZsF4QgSj3N7ZLbVKNW4FFr84IamA2YI0D7PyyNAE2PUZT8n0jHWRJKunuZuy5mgBY8H41KdBI6gNJqY90nHeJw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"BJDNTt8RpPE\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"nXTd5AoT220nBkW6Zeax8caUI7_Tt0y4v9TEW8TOrzCVvhLBiKpQPjILUTfkGHzxPtysEzDQFSYdHWvg_fvGYItjJBunBMsKCNcb2_CDr2HXD6C0s62bAgct8bBSoaT1MLQ_3MaFKXSF3ZuB87X2B8CVUJ386HP2GY1kl54BuMdFELNZYhy9S_D0KHnQls52Vvb99X9WaYOyxvfr03PG-9EycnkWas5tn1pPFzT0DtJtBJ4IBtXQxTr98jpn_MCz1gRnMgzzkfSOcrMkkMXxePqxNINVKFXtRy7DaJiFOcCMbuK2RJUkSfY2uKcx0aKbp5Xhvix1W8N7c0Y90i6_6w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"z8iijSOOIs4\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"rPCHP9XeTGOLf1Ezxeq_bdGdvYQZa993YcSVudT0EN6drTWqjykhUVEkT4MGAvLvax38kLARbPUTgMUV9UckDDWn6lRq4q6IZ5pytNOieQKZHzjEmQGzlbnEn1F2m1i5SAfBL-qsnt5q2RXMAiIUXk9q1ChJEHJxOZxnRIoQMc7yTsjjSdtIZKePFiYFn0nsl3A234ByyIBRjzZeoYEtTQKjDR7fP9LO78oZAgpwoGqmfI4IltqQYkFoqrN8I8l1yiJGyuvZRgDXUZ2fxGOQx2WD4xvlFL2TOCfN1UaPE9R4JdbRLLAOf5u1Sqnh4XTjDBhBbVodsmmbtvk4wFo-GQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"zD76wa11A2Y\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"nMaSioq1An1J3tbkmc-zRrR8lkbP-WUVRuYhDxQvV-OcBw1R6cdyCcoeFJ1zuUT7ne6BlU6GMPRHuRKaH0KuOaiktUYtXm06T_HvtKFgCQSAKjMUj_ZHfTAJP8ahUsIc0D995XKp7nIGRF7Iy7I24QQFPRh7PmGlREZ52GJgYQgbm020-sWani0MqHoUFBlWxZW9NEqY1c3brN_qWnzjRKly6Kkk3sW1XHPcRLvoHnHQ6TKXJ8pfl-bNjTfK6zq9fDCZ_TY3qQZy66yT_2XPO6X0GHTdJsZlCj7Jg0qrilTHUkJra1bppTSAtVSQnSmYt_IV8zOYiVdJ3kw2khPcKw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true}]}" } -} \ No newline at end of file +} diff --git a/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json b/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json index d954f81aa..476d67574 100644 --- a/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json +++ b/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json @@ -1 +1 @@ -{"provider":{"url":"https://localhost:3457","configuration":{"issuer":"https://localhost:3457","authorization_endpoint":"https://localhost:3457/authorize","token_endpoint":"https://localhost:3457/token","userinfo_endpoint":"https://localhost:3457/userinfo","jwks_uri":"https://localhost:3457/jwks","registration_endpoint":"https://localhost:3457/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":"","claims_parameter_supported":false,"request_parameter_supported":false,"request_uri_parameter_supported":true,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:3457/session","end_session_endpoint":"https://localhost:3457/logout"},"jwks":{"keys":[{"kid":"lNZOB-DPE1k","kty":"RSA","alg":"RS256","n":"uvih8HfZj7Wu5Y8knLHxRY6v7oHL2jXWD-B6hXCreYhwaG9EEUt6Rp94p8-JBug3ywo8C_9dNg0RtQLEttcIC_vhqqlJI3pZxpGKXuD9h7XK-PppFVvgnfIGADG0Z-WzbcGDxlefStohR31Hjw5U3ioG3VtXGAYbqlOHM1l2UgDMJwBD5qwFmPP8gp5E2WQKCsuLvxDuOrkAbSDjw2zaI3RRmbLzdj4QkGej8GXhBptgM9RwcKmnoXu0sUdlootmcdiEg74yQ9M6EshNMhiv4k_W0rl7RqVOEL2PsAdmdbF_iWL8a90rGYOEILBrlU6bBR2mTvjV_Hvq-ifFy1YAmQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"Y38YKDtydoE","kty":"RSA","alg":"RS384","n":"tfgZKLjc8UMIblfAlVibJI_2uAxDNprn2VVLebS0sp6d1mtCXQkMYLlJ6e-7kavl8we391Ovnq5bRgpsFRq_LtRX9MpVlfioAUHwWPEG-R6vrQjgo4uynVhI3UEPHyNmZA5J4u34HNVTfAgmquomwwOmOv29ZNRxuYP1kVtscz1JeFPwg6LA7BxWrLc9ev4FQR6tjJKdo2kdLjAXR92odbCzJZ_jdYT3vIVCexMHxhoKnqCImkhfgKbGXcPHXWcelmuA2tzBaLut-Jjo0nJVQjRNDqy0Gyac0TptwFIxaiyHeTqugolUmEaJSfBSLszIRdlOTIGPJ7zdg5dJFK_Lxw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"WyMVv6BJ5Dk","kty":"RSA","alg":"RS512","n":"5JDlpbm2TjSW1wpdUZc5NHOqVVrNH_GumoODK_mk-MqImaIRpdR9b1ZJrK6FrW7HIF2bXvebD7olmp9a1goqe-ILbL_ORmhzlhRtyhjWQ-UOZqK5yOXqXXGQXgmok6TN-s55A-h_g12A7Yk5Y5S8EVa9EA4Axwqvm-Q_AkH0yS1qJo6BXYXb1fx205ucx-Ccot2LEBfxv8M7NOFTa-_G-sNchiKQMRoLhbZtLbSK2R1jkqGciEiRSLeXNG4nDu7Wd91-vhBixA1McxnzW96mW8lQwNXXo4gNH7SjONtYLlPQhZVEbmsQmXrOQN8a5RDkybFOIsbucItizSE9V_D7WQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"UykSj_HLgFA","kty":"RSA","alg":"RS256","n":"u79eQlGJN2XFNR-uEmPVtrB_ENRqaS81o6m63tZ5-PwhGHCwJ7rfVnnnvf6Ij_p91Z9pNpWBIVyZcw6UmQIoIBH-3BfxdaqhBxX9bf_N78TKj8_HU5IYjGijale4gog3kj9W2tJJO7R9iA43msjwLRD7pbAHp1iKFJgVTSXJlyLRbC82Dj4ivsEgJjPGvZt16OsGP5myIQwXEGzSPcEI0R9daZE5iM6xFZosaJ8B77eU-Aj3ciwxUBPi5BSZi2P1ZsF4QgSj3N7ZLbVKNW4FFr84IamA2YI0D7PyyNAE2PUZT8n0jHWRJKunuZuy5mgBY8H41KdBI6gNJqY90nHeJw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"BJDNTt8RpPE","kty":"RSA","alg":"RS384","n":"nXTd5AoT220nBkW6Zeax8caUI7_Tt0y4v9TEW8TOrzCVvhLBiKpQPjILUTfkGHzxPtysEzDQFSYdHWvg_fvGYItjJBunBMsKCNcb2_CDr2HXD6C0s62bAgct8bBSoaT1MLQ_3MaFKXSF3ZuB87X2B8CVUJ386HP2GY1kl54BuMdFELNZYhy9S_D0KHnQls52Vvb99X9WaYOyxvfr03PG-9EycnkWas5tn1pPFzT0DtJtBJ4IBtXQxTr98jpn_MCz1gRnMgzzkfSOcrMkkMXxePqxNINVKFXtRy7DaJiFOcCMbuK2RJUkSfY2uKcx0aKbp5Xhvix1W8N7c0Y90i6_6w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"z8iijSOOIs4","kty":"RSA","alg":"RS512","n":"rPCHP9XeTGOLf1Ezxeq_bdGdvYQZa993YcSVudT0EN6drTWqjykhUVEkT4MGAvLvax38kLARbPUTgMUV9UckDDWn6lRq4q6IZ5pytNOieQKZHzjEmQGzlbnEn1F2m1i5SAfBL-qsnt5q2RXMAiIUXk9q1ChJEHJxOZxnRIoQMc7yTsjjSdtIZKePFiYFn0nsl3A234ByyIBRjzZeoYEtTQKjDR7fP9LO78oZAgpwoGqmfI4IltqQYkFoqrN8I8l1yiJGyuvZRgDXUZ2fxGOQx2WD4xvlFL2TOCfN1UaPE9R4JdbRLLAOf5u1Sqnh4XTjDBhBbVodsmmbtvk4wFo-GQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"zD76wa11A2Y","kty":"RSA","alg":"RS256","n":"nMaSioq1An1J3tbkmc-zRrR8lkbP-WUVRuYhDxQvV-OcBw1R6cdyCcoeFJ1zuUT7ne6BlU6GMPRHuRKaH0KuOaiktUYtXm06T_HvtKFgCQSAKjMUj_ZHfTAJP8ahUsIc0D995XKp7nIGRF7Iy7I24QQFPRh7PmGlREZ52GJgYQgbm020-sWani0MqHoUFBlWxZW9NEqY1c3brN_qWnzjRKly6Kkk3sW1XHPcRLvoHnHQ6TKXJ8pfl-bNjTfK6zq9fDCZ_TY3qQZy66yT_2XPO6X0GHTdJsZlCj7Jg0qrilTHUkJra1bppTSAtVSQnSmYt_IV8zOYiVdJ3kw2khPcKw","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"7f81f70c7c306e96fdb5181f4c1d7222","client_secret":"f764cc06dbeeb2d908e10fb3b418853f","redirect_uris":["https://localhost:3457/api/oidc/rp/https%3A%2F%2Flocalhost%3A3457"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:3457","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:3457/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3Iiwic3ViIjoiN2Y4MWY3MGM3YzMwNmU5NmZkYjUxODFmNGMxZDcyMjIiLCJhdWQiOiI3ZjgxZjcwYzdjMzA2ZTk2ZmRiNTE4MWY0YzFkNzIyMiJ9.fT9uzCeGQXJHV-z1X9Fh2DHavvFpNNSBl7p_XiadxdGcX_TkDI_NAtuwX02bRugas6OToWuISHDEkR2PbVaEp7enhnO_bNuDko8AlmfdXmcNCc8a_VXBX_pDl6oiyQQRBqDDOff1fvvqvaJO1n3ssQMJnwJFHqQ75xLaodCPRiP5z3wfLliLJAYZaQVdOf1sBBpd8pxfSGnN7XkNyit71XlWlJR1Z_TaCYVF0EtZyT56rDT8_SFzvLlGpBU8JsmzoETozNKQnj4Zo9Uks_FtKIFNFUm2Oa3KMynTYHJJWxZ92c0oU4EUN6sjioHjVMOPSAkyeM6_Fv6LY-76kFvUpw","registration_client_uri":"https://localhost:3457/register/7f81f70c7c306e96fdb5181f4c1d7222","client_id_issued_at":1489775058,"client_secret_expires_at":0}} \ No newline at end of file +{"provider":{"url":"https://localhost:3457","configuration":{"issuer":"https://localhost:3457","authorization_endpoint":"https://localhost:3457/authorize","token_endpoint":"https://localhost:3457/token","userinfo_endpoint":"https://localhost:3457/userinfo","jwks_uri":"https://localhost:3457/jwks","registration_endpoint":"https://localhost:3457/register","response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:3457/session","end_session_endpoint":"https://localhost:3457/logout"},"jwks":{"keys":[{"kid":"lNZOB-DPE1k","kty":"RSA","alg":"RS256","n":"uvih8HfZj7Wu5Y8knLHxRY6v7oHL2jXWD-B6hXCreYhwaG9EEUt6Rp94p8-JBug3ywo8C_9dNg0RtQLEttcIC_vhqqlJI3pZxpGKXuD9h7XK-PppFVvgnfIGADG0Z-WzbcGDxlefStohR31Hjw5U3ioG3VtXGAYbqlOHM1l2UgDMJwBD5qwFmPP8gp5E2WQKCsuLvxDuOrkAbSDjw2zaI3RRmbLzdj4QkGej8GXhBptgM9RwcKmnoXu0sUdlootmcdiEg74yQ9M6EshNMhiv4k_W0rl7RqVOEL2PsAdmdbF_iWL8a90rGYOEILBrlU6bBR2mTvjV_Hvq-ifFy1YAmQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"Y38YKDtydoE","kty":"RSA","alg":"RS384","n":"tfgZKLjc8UMIblfAlVibJI_2uAxDNprn2VVLebS0sp6d1mtCXQkMYLlJ6e-7kavl8we391Ovnq5bRgpsFRq_LtRX9MpVlfioAUHwWPEG-R6vrQjgo4uynVhI3UEPHyNmZA5J4u34HNVTfAgmquomwwOmOv29ZNRxuYP1kVtscz1JeFPwg6LA7BxWrLc9ev4FQR6tjJKdo2kdLjAXR92odbCzJZ_jdYT3vIVCexMHxhoKnqCImkhfgKbGXcPHXWcelmuA2tzBaLut-Jjo0nJVQjRNDqy0Gyac0TptwFIxaiyHeTqugolUmEaJSfBSLszIRdlOTIGPJ7zdg5dJFK_Lxw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"WyMVv6BJ5Dk","kty":"RSA","alg":"RS512","n":"5JDlpbm2TjSW1wpdUZc5NHOqVVrNH_GumoODK_mk-MqImaIRpdR9b1ZJrK6FrW7HIF2bXvebD7olmp9a1goqe-ILbL_ORmhzlhRtyhjWQ-UOZqK5yOXqXXGQXgmok6TN-s55A-h_g12A7Yk5Y5S8EVa9EA4Axwqvm-Q_AkH0yS1qJo6BXYXb1fx205ucx-Ccot2LEBfxv8M7NOFTa-_G-sNchiKQMRoLhbZtLbSK2R1jkqGciEiRSLeXNG4nDu7Wd91-vhBixA1McxnzW96mW8lQwNXXo4gNH7SjONtYLlPQhZVEbmsQmXrOQN8a5RDkybFOIsbucItizSE9V_D7WQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"UykSj_HLgFA","kty":"RSA","alg":"RS256","n":"u79eQlGJN2XFNR-uEmPVtrB_ENRqaS81o6m63tZ5-PwhGHCwJ7rfVnnnvf6Ij_p91Z9pNpWBIVyZcw6UmQIoIBH-3BfxdaqhBxX9bf_N78TKj8_HU5IYjGijale4gog3kj9W2tJJO7R9iA43msjwLRD7pbAHp1iKFJgVTSXJlyLRbC82Dj4ivsEgJjPGvZt16OsGP5myIQwXEGzSPcEI0R9daZE5iM6xFZosaJ8B77eU-Aj3ciwxUBPi5BSZi2P1ZsF4QgSj3N7ZLbVKNW4FFr84IamA2YI0D7PyyNAE2PUZT8n0jHWRJKunuZuy5mgBY8H41KdBI6gNJqY90nHeJw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"BJDNTt8RpPE","kty":"RSA","alg":"RS384","n":"nXTd5AoT220nBkW6Zeax8caUI7_Tt0y4v9TEW8TOrzCVvhLBiKpQPjILUTfkGHzxPtysEzDQFSYdHWvg_fvGYItjJBunBMsKCNcb2_CDr2HXD6C0s62bAgct8bBSoaT1MLQ_3MaFKXSF3ZuB87X2B8CVUJ386HP2GY1kl54BuMdFELNZYhy9S_D0KHnQls52Vvb99X9WaYOyxvfr03PG-9EycnkWas5tn1pPFzT0DtJtBJ4IBtXQxTr98jpn_MCz1gRnMgzzkfSOcrMkkMXxePqxNINVKFXtRy7DaJiFOcCMbuK2RJUkSfY2uKcx0aKbp5Xhvix1W8N7c0Y90i6_6w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"z8iijSOOIs4","kty":"RSA","alg":"RS512","n":"rPCHP9XeTGOLf1Ezxeq_bdGdvYQZa993YcSVudT0EN6drTWqjykhUVEkT4MGAvLvax38kLARbPUTgMUV9UckDDWn6lRq4q6IZ5pytNOieQKZHzjEmQGzlbnEn1F2m1i5SAfBL-qsnt5q2RXMAiIUXk9q1ChJEHJxOZxnRIoQMc7yTsjjSdtIZKePFiYFn0nsl3A234ByyIBRjzZeoYEtTQKjDR7fP9LO78oZAgpwoGqmfI4IltqQYkFoqrN8I8l1yiJGyuvZRgDXUZ2fxGOQx2WD4xvlFL2TOCfN1UaPE9R4JdbRLLAOf5u1Sqnh4XTjDBhBbVodsmmbtvk4wFo-GQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"zD76wa11A2Y","kty":"RSA","alg":"RS256","n":"nMaSioq1An1J3tbkmc-zRrR8lkbP-WUVRuYhDxQvV-OcBw1R6cdyCcoeFJ1zuUT7ne6BlU6GMPRHuRKaH0KuOaiktUYtXm06T_HvtKFgCQSAKjMUj_ZHfTAJP8ahUsIc0D995XKp7nIGRF7Iy7I24QQFPRh7PmGlREZ52GJgYQgbm020-sWani0MqHoUFBlWxZW9NEqY1c3brN_qWnzjRKly6Kkk3sW1XHPcRLvoHnHQ6TKXJ8pfl-bNjTfK6zq9fDCZ_TY3qQZy66yT_2XPO6X0GHTdJsZlCj7Jg0qrilTHUkJra1bppTSAtVSQnSmYt_IV8zOYiVdJ3kw2khPcKw","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"client_id":"7f81f70c7c306e96fdb5181f4c1d7222","client_secret":"f764cc06dbeeb2d908e10fb3b418853f","redirect_uris":["https://localhost:3457/api/oidc/rp/https%3A%2F%2Flocalhost%3A3457"],"response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:3457","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:3457/goodbye"],"frontchannel_logout_session_required":false,"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3Iiwic3ViIjoiN2Y4MWY3MGM3YzMwNmU5NmZkYjUxODFmNGMxZDcyMjIiLCJhdWQiOiI3ZjgxZjcwYzdjMzA2ZTk2ZmRiNTE4MWY0YzFkNzIyMiJ9.fT9uzCeGQXJHV-z1X9Fh2DHavvFpNNSBl7p_XiadxdGcX_TkDI_NAtuwX02bRugas6OToWuISHDEkR2PbVaEp7enhnO_bNuDko8AlmfdXmcNCc8a_VXBX_pDl6oiyQQRBqDDOff1fvvqvaJO1n3ssQMJnwJFHqQ75xLaodCPRiP5z3wfLliLJAYZaQVdOf1sBBpd8pxfSGnN7XkNyit71XlWlJR1Z_TaCYVF0EtZyT56rDT8_SFzvLlGpBU8JsmzoETozNKQnj4Zo9Uks_FtKIFNFUm2Oa3KMynTYHJJWxZ92c0oU4EUN6sjioHjVMOPSAkyeM6_Fv6LY-76kFvUpw","registration_client_uri":"https://localhost:3457/register/7f81f70c7c306e96fdb5181f4c1d7222","client_id_issued_at":1489775058,"client_secret_expires_at":0}} diff --git a/test/resources/external-servers/example.com/openid-configuration.json b/test/resources/external-servers/example.com/openid-configuration.json index 5075458b3..35eeacbe5 100644 --- a/test/resources/external-servers/example.com/openid-configuration.json +++ b/test/resources/external-servers/example.com/openid-configuration.json @@ -43,10 +43,10 @@ "claim_types_supported": [ "normal" ], - "claims_supported": "", + "claims_supported": [], "claims_parameter_supported": false, - "request_parameter_supported": false, - "request_uri_parameter_supported": true, + "request_parameter_supported": true, + "request_uri_parameter_supported": false, "require_request_uri_registration": false, "check_session_iframe": "https://example.com/session", "end_session_endpoint": "https://example.com/logout" diff --git a/test/unit/login-request-test.js b/test/unit/login-request-test.js index b1a585af2..42d208a1c 100644 --- a/test/unit/login-request-test.js +++ b/test/unit/login-request-test.js @@ -175,13 +175,13 @@ describe('LoginRequest', () => { }) describe('redirectPostLogin()', () => { - it('should redirect to the /authorize url if redirect_uri is present', () => { + it('should redirect to the /authorize url if client_id is present', () => { let res = HttpMocks.createResponse() - let authUrl = 'https://localhost:8443/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' + let authUrl = 'https://localhost:8443/authorize?client_id=client123' let validUser = accountManager.userAccountFrom({ username: 'alice' }) let authQueryParams = { - redirect_uri: 'https://app.example.com/callback' + client_id: 'client123' } let options = { accountManager, authQueryParams, response: res } @@ -195,9 +195,9 @@ describe('LoginRequest', () => { expect(res._getRedirectUrl()).to.equal(authUrl) }) - it('should redirect to account uri if no redirect_uri present', () => { + it('should redirect to account uri if no client_id present', () => { let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' + let authUrl = 'https://localhost/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' let validUser = accountManager.userAccountFrom({ username: 'alice' }) let authQueryParams = {} diff --git a/test/utils.js b/test/utils.js index 41d3d584e..891eba429 100644 --- a/test/utils.js +++ b/test/utils.js @@ -2,6 +2,7 @@ var fs = require('fs') var fsExtra = require('fs-extra') var rimraf = require('rimraf') var path = require('path') +const OIDCProvider = require('@trust/oidc-op') exports.rm = function (file) { return rimraf.sync(path.join(__dirname, '/resources/' + file)) @@ -22,3 +23,19 @@ exports.read = function (file) { 'encoding': 'utf8' }) } + +/** + * @param configPath {string} + * + * @returns {Promise<Provider>} + */ +exports.loadProvider = function loadProvider (configPath) { + return Promise.resolve() + .then(() => { + const config = require(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} From 0ab52a8f50313ec9a1866e8be6618157a2edf1ee Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Mon, 14 Aug 2017 16:00:37 -0400 Subject: [PATCH 127/178] Add token reuse test --- package.json | 2 +- test/integration/authentication-oidc-test.js | 20 ++++++++++++++++++- .../alice/private-for-alice.txt | 1 + .../alice/private-for-alice.txt.acl | 12 +++++++++++ .../accounts/db/oidc/op/provider.json | 2 +- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 test/resources/accounts-scenario/alice/private-for-alice.txt create mode 100644 test/resources/accounts-scenario/alice/private-for-alice.txt.acl diff --git a/package.json b/package.json index 01acb625e..150ccc6a4 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.8.0", + "oidc-auth-manager": "^0.8.1", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", diff --git a/test/integration/authentication-oidc-test.js b/test/integration/authentication-oidc-test.js index 3ffd28250..fddb49873 100644 --- a/test/integration/authentication-oidc-test.js +++ b/test/integration/authentication-oidc-test.js @@ -325,6 +325,7 @@ describe('Authentication API (OIDC)', () => { let auth let authorizationUri, loginUri, authParams, callbackUri let loginFormFields = '' + let bearerToken before(() => { auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) @@ -460,9 +461,11 @@ describe('Authentication API (OIDC)', () => { return auth.issuePoPTokenFor(bobServerUri, auth.session) }) .then(popToken => { + bearerToken = popToken + return fetch(protectedResourcePath, { headers: { - 'Authorization': 'Bearer ' + popToken + 'Authorization': 'Bearer ' + bearerToken } }) }) @@ -475,6 +478,21 @@ describe('Authentication API (OIDC)', () => { expect(contents).to.equal('protected contents\n') }) }) + + it('should not be able to reuse the bearer token for bob server on another server', () => { + let privateAliceResourcePath = aliceServerUri + '/private-for-alice.txt' + + return fetch(privateAliceResourcePath, { + headers: { + // This is Alice's bearer token with her own Web ID + 'Authorization': 'Bearer ' + bearerToken + } + }) + .then(res => { + // It will get rejected; it was issued for Bob's server only + expect(res.status).to.equal(403) + }) + }) }) describe('Post-logout page (GET /goodbye)', () => { diff --git a/test/resources/accounts-scenario/alice/private-for-alice.txt b/test/resources/accounts-scenario/alice/private-for-alice.txt new file mode 100644 index 000000000..3dd4d7a1a --- /dev/null +++ b/test/resources/accounts-scenario/alice/private-for-alice.txt @@ -0,0 +1 @@ +protected contents for alice diff --git a/test/resources/accounts-scenario/alice/private-for-alice.txt.acl b/test/resources/accounts-scenario/alice/private-for-alice.txt.acl new file mode 100644 index 000000000..f4771bb0b --- /dev/null +++ b/test/resources/accounts-scenario/alice/private-for-alice.txt.acl @@ -0,0 +1,12 @@ +<#Alice> + a <http://www.w3.org/ns/auth/acl#Authorization> ; + + <http://www.w3.org/ns/auth/acl#accessTo> <./private-for-alice.txt>; + + # Alice web id + <http://www.w3.org/ns/auth/acl#agent> <https://localhost:7000/profile/card#me>; + + <http://www.w3.org/ns/auth/acl#mode> + <http://www.w3.org/ns/auth/acl#Read>, + <http://www.w3.org/ns/auth/acl#Write>, + <http://www.w3.org/ns/auth/acl#Control> . diff --git a/test/resources/accounts/db/oidc/op/provider.json b/test/resources/accounts/db/oidc/op/provider.json index 27f678946..50f8790bb 100644 --- a/test/resources/accounts/db/oidc/op/provider.json +++ b/test/resources/accounts/db/oidc/op/provider.json @@ -408,4 +408,4 @@ }, "jwkSet": "{\"keys\":[{\"kid\":\"lNZOB-DPE1k\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"uvih8HfZj7Wu5Y8knLHxRY6v7oHL2jXWD-B6hXCreYhwaG9EEUt6Rp94p8-JBug3ywo8C_9dNg0RtQLEttcIC_vhqqlJI3pZxpGKXuD9h7XK-PppFVvgnfIGADG0Z-WzbcGDxlefStohR31Hjw5U3ioG3VtXGAYbqlOHM1l2UgDMJwBD5qwFmPP8gp5E2WQKCsuLvxDuOrkAbSDjw2zaI3RRmbLzdj4QkGej8GXhBptgM9RwcKmnoXu0sUdlootmcdiEg74yQ9M6EshNMhiv4k_W0rl7RqVOEL2PsAdmdbF_iWL8a90rGYOEILBrlU6bBR2mTvjV_Hvq-ifFy1YAmQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"Y38YKDtydoE\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"tfgZKLjc8UMIblfAlVibJI_2uAxDNprn2VVLebS0sp6d1mtCXQkMYLlJ6e-7kavl8we391Ovnq5bRgpsFRq_LtRX9MpVlfioAUHwWPEG-R6vrQjgo4uynVhI3UEPHyNmZA5J4u34HNVTfAgmquomwwOmOv29ZNRxuYP1kVtscz1JeFPwg6LA7BxWrLc9ev4FQR6tjJKdo2kdLjAXR92odbCzJZ_jdYT3vIVCexMHxhoKnqCImkhfgKbGXcPHXWcelmuA2tzBaLut-Jjo0nJVQjRNDqy0Gyac0TptwFIxaiyHeTqugolUmEaJSfBSLszIRdlOTIGPJ7zdg5dJFK_Lxw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"WyMVv6BJ5Dk\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"5JDlpbm2TjSW1wpdUZc5NHOqVVrNH_GumoODK_mk-MqImaIRpdR9b1ZJrK6FrW7HIF2bXvebD7olmp9a1goqe-ILbL_ORmhzlhRtyhjWQ-UOZqK5yOXqXXGQXgmok6TN-s55A-h_g12A7Yk5Y5S8EVa9EA4Axwqvm-Q_AkH0yS1qJo6BXYXb1fx205ucx-Ccot2LEBfxv8M7NOFTa-_G-sNchiKQMRoLhbZtLbSK2R1jkqGciEiRSLeXNG4nDu7Wd91-vhBixA1McxnzW96mW8lQwNXXo4gNH7SjONtYLlPQhZVEbmsQmXrOQN8a5RDkybFOIsbucItizSE9V_D7WQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"UykSj_HLgFA\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"u79eQlGJN2XFNR-uEmPVtrB_ENRqaS81o6m63tZ5-PwhGHCwJ7rfVnnnvf6Ij_p91Z9pNpWBIVyZcw6UmQIoIBH-3BfxdaqhBxX9bf_N78TKj8_HU5IYjGijale4gog3kj9W2tJJO7R9iA43msjwLRD7pbAHp1iKFJgVTSXJlyLRbC82Dj4ivsEgJjPGvZt16OsGP5myIQwXEGzSPcEI0R9daZE5iM6xFZosaJ8B77eU-Aj3ciwxUBPi5BSZi2P1ZsF4QgSj3N7ZLbVKNW4FFr84IamA2YI0D7PyyNAE2PUZT8n0jHWRJKunuZuy5mgBY8H41KdBI6gNJqY90nHeJw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"BJDNTt8RpPE\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"n\":\"nXTd5AoT220nBkW6Zeax8caUI7_Tt0y4v9TEW8TOrzCVvhLBiKpQPjILUTfkGHzxPtysEzDQFSYdHWvg_fvGYItjJBunBMsKCNcb2_CDr2HXD6C0s62bAgct8bBSoaT1MLQ_3MaFKXSF3ZuB87X2B8CVUJ386HP2GY1kl54BuMdFELNZYhy9S_D0KHnQls52Vvb99X9WaYOyxvfr03PG-9EycnkWas5tn1pPFzT0DtJtBJ4IBtXQxTr98jpn_MCz1gRnMgzzkfSOcrMkkMXxePqxNINVKFXtRy7DaJiFOcCMbuK2RJUkSfY2uKcx0aKbp5Xhvix1W8N7c0Y90i6_6w\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"z8iijSOOIs4\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"n\":\"rPCHP9XeTGOLf1Ezxeq_bdGdvYQZa993YcSVudT0EN6drTWqjykhUVEkT4MGAvLvax38kLARbPUTgMUV9UckDDWn6lRq4q6IZ5pytNOieQKZHzjEmQGzlbnEn1F2m1i5SAfBL-qsnt5q2RXMAiIUXk9q1ChJEHJxOZxnRIoQMc7yTsjjSdtIZKePFiYFn0nsl3A234ByyIBRjzZeoYEtTQKjDR7fP9LO78oZAgpwoGqmfI4IltqQYkFoqrN8I8l1yiJGyuvZRgDXUZ2fxGOQx2WD4xvlFL2TOCfN1UaPE9R4JdbRLLAOf5u1Sqnh4XTjDBhBbVodsmmbtvk4wFo-GQ\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true},{\"kid\":\"zD76wa11A2Y\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"n\":\"nMaSioq1An1J3tbkmc-zRrR8lkbP-WUVRuYhDxQvV-OcBw1R6cdyCcoeFJ1zuUT7ne6BlU6GMPRHuRKaH0KuOaiktUYtXm06T_HvtKFgCQSAKjMUj_ZHfTAJP8ahUsIc0D995XKp7nIGRF7Iy7I24QQFPRh7PmGlREZ52GJgYQgbm020-sWani0MqHoUFBlWxZW9NEqY1c3brN_qWnzjRKly6Kkk3sW1XHPcRLvoHnHQ6TKXJ8pfl-bNjTfK6zq9fDCZ_TY3qQZy66yT_2XPO6X0GHTdJsZlCj7Jg0qrilTHUkJra1bppTSAtVSQnSmYt_IV8zOYiVdJ3kw2khPcKw\",\"e\":\"AQAB\",\"key_ops\":[\"verify\"],\"ext\":true}]}" } -} +} \ No newline at end of file From ff3d3bebd0f275515ab0fab11a670580bd60f194 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 15 Aug 2017 09:59:57 -0400 Subject: [PATCH 128/178] Add package-lock.json --- package-lock.json | 758 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 692 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d504e778..f2413d098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/@trust/jose/-/jose-0.1.7.tgz", "integrity": "sha512-JlWY97+Q1pU2CN08Ux5oN1/CXcvxLtQ5YkL4UhgVs4z9TR/+I4rKqhqoZQ0TDGPvCLP1QaT7F6bHbKswbDwgOQ==", + "dev": true, "requires": { "@trust/json-document": "0.1.4", "@trust/webcrypto": "0.0.2", @@ -19,6 +20,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.0.2.tgz", "integrity": "sha1-53xpouYSudOSJRxZZscxaFN+Jmc=", + "dev": true, "requires": { "base64url": "2.0.0", "node-rsa": "0.4.2", @@ -31,12 +33,14 @@ "@trust/json-document": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@trust/json-document/-/json-document-0.1.4.tgz", - "integrity": "sha1-sgI7HhRbp2hb0fNux7aRKJQAc+k=" + "integrity": "sha1-sgI7HhRbp2hb0fNux7aRKJQAc+k=", + "dev": true }, "@trust/keychain": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@trust/keychain/-/keychain-0.1.2.tgz", "integrity": "sha512-xB023VTJ8TNJPMMkhqGDZ+qdQhWgRunzEn2bMoaLX3Jyup9Fh6IJGFFTYg9Lj3mjDfvzVRUJPSQwWnH6sE48kQ==", + "dev": true, "requires": { "@trust/webcrypto": "0.3.0", "base64url": "2.0.0" @@ -46,6 +50,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@trust/keyto/-/keyto-0.3.1.tgz", "integrity": "sha1-Q96AKTD4JvxZj/73VtqBR42GEaM=", + "dev": true, "requires": { "asn1.js": "4.9.1", "base64url": "2.0.0", @@ -56,6 +61,7 @@ "version": "4.9.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.1.tgz", "integrity": "sha1-SLokC0WpKA6UdImQull9IWYX/UA=", + "dev": true, "requires": { "bn.js": "4.11.8", "inherits": "2.0.3", @@ -65,7 +71,8 @@ "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true } } }, @@ -73,6 +80,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@trust/oidc-op/-/oidc-op-0.3.0.tgz", "integrity": "sha1-ivKsyXS5p+5048PUINMj8mTNSpo=", + "dev": true, "requires": { "@trust/jose": "0.1.7", "@trust/json-document": "0.1.4", @@ -87,6 +95,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@trust/oidc-rp/-/oidc-rp-0.4.1.tgz", "integrity": "sha512-ky6GQ+EuSTW4+9eylvwdIrPme1rvi42HnYiPDatGUN/CEgq89vuRzS4HNu+K78DXFEKt+8uvQZHF5/RDlsLXEg==", + "dev": true, "requires": { "@trust/jose": "0.1.7", "@trust/json-document": "0.1.4", @@ -97,19 +106,11 @@ "urlutils": "0.0.3" } }, - "@trust/oidc-rs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@trust/oidc-rs/-/oidc-rs-0.2.1.tgz", - "integrity": "sha1-sRawS3qF1hsNJMXkU5ej3dqbmnI=", - "requires": { - "@trust/jose": "0.1.7", - "node-fetch": "1.7.2" - } - }, "@trust/webcrypto": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.3.0.tgz", "integrity": "sha1-F8xeEOt9nKt07YvcPf4PYcqhiTc=", + "dev": true, "requires": { "@trust/keyto": "0.3.1", "base64url": "2.0.0", @@ -275,6 +276,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-1.0.3.tgz", "integrity": "sha1-KBuj7B8kSP52X5Kk7s+IP+E2S1Q=", + "dev": true, "requires": { "bn.js": "1.3.0", "inherits": "2.0.3", @@ -297,7 +299,8 @@ "assertion-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz", - "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=" + "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=", + "dev": true }, "astw": { "version": "2.2.0", @@ -351,7 +354,8 @@ "base64url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64url/-/base64url-2.0.0.tgz", - "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=" + "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=", + "dev": true }, "bcrypt-pbkdf": { "version": "1.0.1", @@ -362,11 +366,6 @@ "tweetnacl": "0.14.5" } }, - "bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" - }, "bl": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz", @@ -402,6 +401,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-1.3.0.tgz", "integrity": "sha1-DbTL+W+PI7dC9by50ap6mZSgXoM=", + "dev": true, "optional": true }, "body-parser": { @@ -493,7 +493,8 @@ "browser-stdout": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", - "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=" + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true }, "browserify": { "version": "14.4.0", @@ -824,7 +825,8 @@ "check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=" + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true }, "cipher-base": { "version": "1.0.4", @@ -1155,6 +1157,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-2.0.2.tgz", "integrity": "sha1-sbrAblbwp2d3aG1Qyf63XC7XZ5o=", + "dev": true, "requires": { "type-detect": "3.0.0" }, @@ -1162,7 +1165,8 @@ "type-detect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-3.0.0.tgz", - "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=" + "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=", + "dev": true } } }, @@ -1277,7 +1281,8 @@ "diff": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", - "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=" + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true }, "diffie-hellman": { "version": "5.0.2", @@ -2051,7 +2056,8 @@ "get-func-name": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=" + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true }, "get-stdin": { "version": "5.0.1", @@ -2162,12 +2168,14 @@ "graceful-readlink": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true }, "growl": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", - "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=" + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true }, "handlebars": { "version": "4.0.10", @@ -2218,7 +2226,8 @@ "has-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true }, "hash-base": { "version": "2.0.2", @@ -2816,7 +2825,8 @@ "json3": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", - "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true }, "jsonfile": { "version": "2.4.0", @@ -2894,14 +2904,6 @@ "is-buffer": "1.1.5" } }, - "kvplus-files": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/kvplus-files/-/kvplus-files-0.0.4.tgz", - "integrity": "sha1-bxqmr2sKaThO0GQY4YSwnHDqNUE=", - "requires": { - "fs-extra": "2.1.2" - } - }, "labeled-stream-splicer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.0.tgz", @@ -2956,6 +2958,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, "requires": { "lodash._basecopy": "3.0.1", "lodash.keys": "3.1.2" @@ -2964,27 +2967,32 @@ "lodash._basecopy": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", - "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=" + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true }, "lodash._basecreate": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", - "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=" + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", + "dev": true }, "lodash._getnative": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true }, "lodash._isiterateecall": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", - "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=" + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true }, "lodash.create": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "dev": true, "requires": { "lodash._baseassign": "3.2.0", "lodash._basecreate": "3.0.3", @@ -2994,17 +3002,20 @@ "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true }, "lodash.isarray": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true }, "lodash.keys": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, "requires": { "lodash._getnative": "3.9.1", "lodash.isarguments": "3.1.0", @@ -3144,6 +3155,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, "requires": { "minimist": "0.0.8" }, @@ -3151,7 +3163,8 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true } } }, @@ -3159,6 +3172,7 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.0.tgz", "integrity": "sha512-pIU2PJjrPYvYRqVpjXzj76qltO9uBYI7woYAMoxbSefsa+vqAfptjoeevd6bUgwD0mPIO+hv9f7ltvsNreL2PA==", + "dev": true, "requires": { "browser-stdout": "1.3.0", "commander": "2.9.0", @@ -3177,6 +3191,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, "requires": { "graceful-readlink": "1.0.1" } @@ -3185,6 +3200,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "dev": true, "requires": { "fs.realpath": "1.0.0", "inflight": "1.0.6", @@ -3198,6 +3214,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "dev": true, "requires": { "has-flag": "1.0.0" } @@ -3334,7 +3351,8 @@ "net": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz", - "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=" + "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=", + "dev": true }, "nock": { "version": "9.0.14", @@ -3371,6 +3389,7 @@ "version": "1.6.4", "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.6.4.tgz", "integrity": "sha1-ea47ym9HJ5HmGNqjOPu2KJJR0tc=", + "dev": true, "requires": { "accepts": "1.3.3", "depd": "1.1.1", @@ -3387,7 +3406,8 @@ "fresh": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz", - "integrity": "sha1-ZR+DjiJCTnVm3hYdg1jKoZn4PU8=" + "integrity": "sha1-ZR+DjiJCTnVm3hYdg1jKoZn4PU8=", + "dev": true } } }, @@ -3395,6 +3415,7 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz", "integrity": "sha1-1jkXKewWqDDtWjgEKzFX0tXXJTA=", + "dev": true, "requires": { "asn1": "0.2.3" } @@ -4889,23 +4910,635 @@ "valid-url": "1.0.9" }, "dependencies": { + "@trust/jose": { + "version": "0.1.7", + "bundled": true, + "requires": { + "@trust/json-document": "0.1.4", + "@trust/webcrypto": "0.0.2", + "base64url": "2.0.0", + "text-encoding": "0.6.4" + }, + "dependencies": { + "@trust/webcrypto": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.0.2.tgz", + "integrity": "sha1-53xpouYSudOSJRxZZscxaFN+Jmc=", + "requires": { + "base64url": "2.0.0", + "node-rsa": "0.4.2", + "pem-jwk": "1.5.1", + "text-encoding": "0.6.4" + } + } + } + }, + "@trust/json-document": { + "version": "0.1.4", + "bundled": true + }, + "@trust/keychain": { + "version": "0.1.2", + "bundled": true, + "requires": { + "@trust/webcrypto": "0.3.0", + "base64url": "2.0.0" + } + }, + "@trust/keyto": { + "version": "0.3.1", + "bundled": true, + "requires": { + "asn1.js": "4.9.1", + "base64url": "2.0.0", + "elliptic": "6.4.0" + }, + "dependencies": { + "asn1.js": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.1.tgz", + "integrity": "sha1-SLokC0WpKA6UdImQull9IWYX/UA=", + "requires": { + "bn.js": "4.11.8", + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + } + } + }, + "@trust/oidc-op": { + "version": "0.3.0", + "bundled": true, + "requires": { + "@trust/jose": "0.1.7", + "@trust/json-document": "0.1.4", + "@trust/keychain": "0.1.2", + "@trust/webcrypto": "0.3.0", + "base64url": "2.0.0", + "pem-jwk": "1.5.1", + "qs": "6.5.0" + } + }, + "@trust/oidc-rp": { + "version": "0.4.1", + "bundled": true, + "requires": { + "@trust/jose": "0.1.7", + "@trust/json-document": "0.1.4", + "@trust/webcrypto": "0.3.0", + "base64url": "2.0.0", + "node-fetch": "1.7.2", + "text-encoding": "0.6.4", + "urlutils": "0.0.3" + } + }, + "@trust/oidc-rs": { + "version": "0.2.1", + "bundled": true, + "requires": { + "@trust/jose": "0.1.7", + "node-fetch": "1.7.2" + } + }, + "@trust/webcrypto": { + "version": "0.3.0", + "bundled": true, + "requires": { + "@trust/keyto": "0.3.1", + "base64url": "2.0.0", + "node-rsa": "0.4.2", + "text-encoding": "0.6.4" + } + }, + "accepts": { + "version": "1.3.3", + "bundled": true, + "requires": { + "mime-types": "2.1.16", + "negotiator": "0.6.1" + } + }, + "asn1": { + "version": "0.2.3", + "bundled": true + }, + "asn1.js": { + "version": "1.0.3", + "bundled": true, + "requires": { + "bn.js": "1.3.0", + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "assertion-error": { + "version": "1.0.2", + "bundled": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true + }, + "base64url": { + "version": "2.0.0", + "bundled": true + }, + "bcryptjs": { + "version": "2.4.3", + "bundled": true + }, + "bn.js": { + "version": "1.3.0", + "bundled": true, + "optional": true + }, + "brace-expansion": { + "version": "1.1.8", + "bundled": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "brorand": { + "version": "1.1.0", + "bundled": true + }, + "browser-stdout": { + "version": "1.3.0", + "bundled": true + }, + "chai": { + "version": "4.1.1", + "bundled": true, + "requires": { + "assertion-error": "1.0.2", + "check-error": "1.0.2", + "deep-eql": "2.0.2", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.3" + } + }, + "check-error": { + "version": "1.0.2", + "bundled": true + }, + "commander": { + "version": "2.9.0", + "bundled": true, + "requires": { + "graceful-readlink": "1.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "bundled": true + }, + "debug": { + "version": "2.6.8", + "bundled": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-eql": { + "version": "2.0.2", + "bundled": true, + "requires": { + "type-detect": "3.0.0" + }, + "dependencies": { + "type-detect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-3.0.0.tgz", + "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=" + } + } + }, + "depd": { + "version": "1.1.1", + "bundled": true + }, + "diff": { + "version": "3.2.0", + "bundled": true + }, + "elliptic": { + "version": "6.4.0", + "bundled": true, + "requires": { + "bn.js": "4.11.8", + "brorand": "1.1.0", + "hash.js": "1.1.3", + "hmac-drbg": "1.0.1", + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0", + "minimalistic-crypto-utils": "1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + } + } + }, + "encoding": { + "version": "0.1.12", + "bundled": true, + "requires": { + "iconv-lite": "0.4.18" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "bundled": true + }, + "fresh": { + "version": "0.3.0", + "bundled": true + }, "fs-extra": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.1.tgz", - "integrity": "sha1-f8DGyJV/mD9X8waiTlud3Y0N2IA=", + "bundled": true, "requires": { "graceful-fs": "4.1.11", "jsonfile": "3.0.1", "universalify": "0.1.1" } }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true + }, + "get-func-name": { + "version": "2.0.0", + "bundled": true + }, + "glob": { + "version": "7.1.1", + "bundled": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true + }, + "graceful-readlink": { + "version": "1.0.1", + "bundled": true + }, + "growl": { + "version": "1.9.2", + "bundled": true + }, + "has-flag": { + "version": "1.0.0", + "bundled": true + }, + "hash.js": { + "version": "1.1.3", + "bundled": true, + "requires": { + "inherits": "2.0.3", + "minimalistic-assert": "1.0.0" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "bundled": true, + "requires": { + "hash.js": "1.1.3", + "minimalistic-assert": "1.0.0", + "minimalistic-crypto-utils": "1.0.1" + } + }, + "iconv-lite": { + "version": "0.4.18", + "bundled": true + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true + }, + "is-stream": { + "version": "1.1.0", + "bundled": true + }, + "json3": { + "version": "3.3.2", + "bundled": true + }, "jsonfile": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", - "integrity": "sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=", + "bundled": true, "requires": { "graceful-fs": "4.1.11" } + }, + "kvplus-files": { + "version": "0.0.4", + "bundled": true, + "requires": { + "fs-extra": "2.1.2" + }, + "dependencies": { + "fs-extra": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", + "integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=", + "requires": { + "graceful-fs": "4.1.11", + "jsonfile": "2.4.0" + } + }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "requires": { + "graceful-fs": "4.1.11" + } + } + } + }, + "li": { + "version": "1.2.1", + "bundled": true + }, + "lodash._baseassign": { + "version": "3.2.0", + "bundled": true, + "requires": { + "lodash._basecopy": "3.0.1", + "lodash.keys": "3.1.2" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "bundled": true + }, + "lodash._basecreate": { + "version": "3.0.3", + "bundled": true + }, + "lodash._getnative": { + "version": "3.9.1", + "bundled": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "bundled": true + }, + "lodash.create": { + "version": "3.1.1", + "bundled": true, + "requires": { + "lodash._baseassign": "3.2.0", + "lodash._basecreate": "3.0.3", + "lodash._isiterateecall": "3.0.9" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "bundled": true + }, + "lodash.isarray": { + "version": "3.0.4", + "bundled": true + }, + "lodash.keys": { + "version": "3.1.2", + "bundled": true, + "requires": { + "lodash._getnative": "3.9.1", + "lodash.isarguments": "3.1.0", + "lodash.isarray": "3.0.4" + } + }, + "media-typer": { + "version": "0.3.0", + "bundled": true + }, + "merge-descriptors": { + "version": "1.0.1", + "bundled": true + }, + "methods": { + "version": "1.1.2", + "bundled": true + }, + "mime": { + "version": "1.3.6", + "bundled": true + }, + "mime-db": { + "version": "1.29.0", + "bundled": true + }, + "mime-types": { + "version": "2.1.16", + "bundled": true, + "requires": { + "mime-db": "1.29.0" + } + }, + "minimalistic-assert": { + "version": "1.0.0", + "bundled": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "bundled": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "3.5.0", + "bundled": true, + "requires": { + "browser-stdout": "1.3.0", + "commander": "2.9.0", + "debug": "2.6.8", + "diff": "3.2.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.1", + "growl": "1.9.2", + "json3": "3.3.2", + "lodash.create": "3.1.1", + "mkdirp": "0.5.1", + "supports-color": "3.1.2" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true + }, + "negotiator": { + "version": "0.6.1", + "bundled": true + }, + "net": { + "version": "1.0.2", + "bundled": true + }, + "node-fetch": { + "version": "1.7.2", + "bundled": true, + "requires": { + "encoding": "0.1.12", + "is-stream": "1.1.0" + } + }, + "node-mocks-http": { + "version": "1.6.4", + "bundled": true, + "requires": { + "accepts": "1.3.3", + "depd": "1.1.1", + "fresh": "0.3.0", + "merge-descriptors": "1.0.1", + "methods": "1.1.2", + "mime": "1.3.6", + "net": "1.0.2", + "parseurl": "1.3.1", + "range-parser": "1.2.0", + "type-is": "1.6.15" + } + }, + "node-rsa": { + "version": "0.4.2", + "bundled": true, + "requires": { + "asn1": "0.2.3" + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "parseurl": { + "version": "1.3.1", + "bundled": true + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true + }, + "pathval": { + "version": "1.1.0", + "bundled": true + }, + "pem-jwk": { + "version": "1.5.1", + "bundled": true, + "requires": { + "asn1.js": "1.0.3" + } + }, + "qs": { + "version": "6.5.0", + "bundled": true + }, + "range-parser": { + "version": "1.2.0", + "bundled": true + }, + "solid-multi-rp-client": { + "version": "0.2.0", + "bundled": true, + "requires": { + "@trust/oidc-rp": "0.4.1", + "kvplus-files": "0.0.4" + } + }, + "supports-color": { + "version": "3.1.2", + "bundled": true, + "requires": { + "has-flag": "1.0.0" + } + }, + "text-encoding": { + "version": "0.6.4", + "bundled": true + }, + "type-detect": { + "version": "4.0.3", + "bundled": true + }, + "type-is": { + "version": "1.6.15", + "bundled": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "2.1.16" + } + }, + "universalify": { + "version": "0.1.1", + "bundled": true + }, + "URIjs": { + "version": "1.16.1", + "bundled": true + }, + "urlutils": { + "version": "0.0.3", + "bundled": true, + "requires": { + "chai": "4.1.1", + "mocha": "3.5.0", + "URIjs": "1.16.1" + } + }, + "valid-url": { + "version": "1.0.9", + "bundled": true + }, + "wrappy": { + "version": "1.0.2", + "bundled": true } } }, @@ -5108,7 +5741,8 @@ "pathval": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=" + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true }, "pbkdf2": { "version": "3.0.13", @@ -5126,6 +5760,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/pem-jwk/-/pem-jwk-1.5.1.tgz", "integrity": "sha1-eoY3/S9nqCflfAxC4cI8P9Us+wE=", + "dev": true, "requires": { "asn1.js": "1.0.3" } @@ -5776,15 +6411,6 @@ "@trust/oidc-rp": "0.4.1" } }, - "solid-multi-rp-client": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.0.tgz", - "integrity": "sha1-5T+356YcC1lVEt67S+B3EhUEJjU=", - "requires": { - "@trust/oidc-rp": "0.4.1", - "kvplus-files": "0.0.4" - } - }, "solid-namespace": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/solid-namespace/-/solid-namespace-0.1.0.tgz", @@ -6242,7 +6868,8 @@ "text-encoding": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", - "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true }, "text-table": { "version": "0.2.0", @@ -6365,7 +6992,8 @@ "type-detect": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.3.tgz", - "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=" + "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=", + "dev": true }, "type-is": { "version": "1.6.15", @@ -6440,11 +7068,6 @@ "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", "dev": true }, - "universalify": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", - "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -6453,7 +7076,8 @@ "URIjs": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/URIjs/-/URIjs-1.16.1.tgz", - "integrity": "sha1-7evGeLi3SyawXStIHhI4P1rgS4s=" + "integrity": "sha1-7evGeLi3SyawXStIHhI4P1rgS4s=", + "dev": true }, "url": { "version": "0.11.0", @@ -6475,6 +7099,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/urlutils/-/urlutils-0.0.3.tgz", "integrity": "sha1-aw9e2ibjY1Jc7hpnkJippO/nrd4=", + "dev": true, "requires": { "chai": "4.1.1", "mocha": "3.5.0", @@ -6485,6 +7110,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.1.tgz", "integrity": "sha1-ZuISeebzxkFf+CMYeCJ5AOIXGzk=", + "dev": true, "requires": { "assertion-error": "1.0.2", "check-error": "1.0.2", From 9ba7cb3a465f501d753d4d336cb28041fd8b6851 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 15 Aug 2017 10:18:11 -0400 Subject: [PATCH 129/178] Switch to the official oidc issuer link rel value --- lib/handlers/options.js | 2 +- package-lock.json | 764 ++---------------- package.json | 2 +- test/integration/capability-discovery-test.js | 4 +- 4 files changed, 73 insertions(+), 699 deletions(-) diff --git a/lib/handlers/options.js b/lib/handlers/options.js index 20b19eab3..f37c99f5e 100644 --- a/lib/handlers/options.js +++ b/lib/handlers/options.js @@ -17,7 +17,7 @@ function linkAuthProvider (req, res) { let locals = req.app.locals if (locals.authMethod === 'oidc') { let oidcProviderUri = locals.host.serverUri - addLink(res, oidcProviderUri, 'oidc.provider') + addLink(res, oidcProviderUri, 'http://openid.net/specs/connect/1.0/issuer') } } diff --git a/package-lock.json b/package-lock.json index f2413d098..84cd2a14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/@trust/jose/-/jose-0.1.7.tgz", "integrity": "sha512-JlWY97+Q1pU2CN08Ux5oN1/CXcvxLtQ5YkL4UhgVs4z9TR/+I4rKqhqoZQ0TDGPvCLP1QaT7F6bHbKswbDwgOQ==", - "dev": true, "requires": { "@trust/json-document": "0.1.4", "@trust/webcrypto": "0.0.2", @@ -20,7 +19,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.0.2.tgz", "integrity": "sha1-53xpouYSudOSJRxZZscxaFN+Jmc=", - "dev": true, "requires": { "base64url": "2.0.0", "node-rsa": "0.4.2", @@ -33,14 +31,12 @@ "@trust/json-document": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@trust/json-document/-/json-document-0.1.4.tgz", - "integrity": "sha1-sgI7HhRbp2hb0fNux7aRKJQAc+k=", - "dev": true + "integrity": "sha1-sgI7HhRbp2hb0fNux7aRKJQAc+k=" }, "@trust/keychain": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@trust/keychain/-/keychain-0.1.2.tgz", "integrity": "sha512-xB023VTJ8TNJPMMkhqGDZ+qdQhWgRunzEn2bMoaLX3Jyup9Fh6IJGFFTYg9Lj3mjDfvzVRUJPSQwWnH6sE48kQ==", - "dev": true, "requires": { "@trust/webcrypto": "0.3.0", "base64url": "2.0.0" @@ -50,7 +46,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@trust/keyto/-/keyto-0.3.1.tgz", "integrity": "sha1-Q96AKTD4JvxZj/73VtqBR42GEaM=", - "dev": true, "requires": { "asn1.js": "4.9.1", "base64url": "2.0.0", @@ -61,7 +56,6 @@ "version": "4.9.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.1.tgz", "integrity": "sha1-SLokC0WpKA6UdImQull9IWYX/UA=", - "dev": true, "requires": { "bn.js": "4.11.8", "inherits": "2.0.3", @@ -71,8 +65,7 @@ "bn.js": { "version": "4.11.8", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", - "dev": true + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" } } }, @@ -80,7 +73,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@trust/oidc-op/-/oidc-op-0.3.0.tgz", "integrity": "sha1-ivKsyXS5p+5048PUINMj8mTNSpo=", - "dev": true, "requires": { "@trust/jose": "0.1.7", "@trust/json-document": "0.1.4", @@ -95,7 +87,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@trust/oidc-rp/-/oidc-rp-0.4.1.tgz", "integrity": "sha512-ky6GQ+EuSTW4+9eylvwdIrPme1rvi42HnYiPDatGUN/CEgq89vuRzS4HNu+K78DXFEKt+8uvQZHF5/RDlsLXEg==", - "dev": true, "requires": { "@trust/jose": "0.1.7", "@trust/json-document": "0.1.4", @@ -106,11 +97,19 @@ "urlutils": "0.0.3" } }, + "@trust/oidc-rs": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@trust/oidc-rs/-/oidc-rs-0.2.1.tgz", + "integrity": "sha1-sRawS3qF1hsNJMXkU5ej3dqbmnI=", + "requires": { + "@trust/jose": "0.1.7", + "node-fetch": "1.7.2" + } + }, "@trust/webcrypto": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.3.0.tgz", "integrity": "sha1-F8xeEOt9nKt07YvcPf4PYcqhiTc=", - "dev": true, "requires": { "@trust/keyto": "0.3.1", "base64url": "2.0.0", @@ -276,7 +275,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-1.0.3.tgz", "integrity": "sha1-KBuj7B8kSP52X5Kk7s+IP+E2S1Q=", - "dev": true, "requires": { "bn.js": "1.3.0", "inherits": "2.0.3", @@ -299,8 +297,7 @@ "assertion-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz", - "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=", - "dev": true + "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=" }, "astw": { "version": "2.2.0", @@ -354,8 +351,7 @@ "base64url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64url/-/base64url-2.0.0.tgz", - "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=", - "dev": true + "integrity": "sha1-6sFuA+oUOO/5Qj1puqNiYu0fcLs=" }, "bcrypt-pbkdf": { "version": "1.0.1", @@ -366,6 +362,11 @@ "tweetnacl": "0.14.5" } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, "bl": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz", @@ -401,7 +402,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-1.3.0.tgz", "integrity": "sha1-DbTL+W+PI7dC9by50ap6mZSgXoM=", - "dev": true, "optional": true }, "body-parser": { @@ -493,8 +493,7 @@ "browser-stdout": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", - "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", - "dev": true + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=" }, "browserify": { "version": "14.4.0", @@ -825,8 +824,7 @@ "check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", - "dev": true + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=" }, "cipher-base": { "version": "1.0.4", @@ -1157,7 +1155,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-2.0.2.tgz", "integrity": "sha1-sbrAblbwp2d3aG1Qyf63XC7XZ5o=", - "dev": true, "requires": { "type-detect": "3.0.0" }, @@ -1165,8 +1162,7 @@ "type-detect": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-3.0.0.tgz", - "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=", - "dev": true + "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=" } } }, @@ -1281,8 +1277,7 @@ "diff": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", - "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", - "dev": true + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=" }, "diffie-hellman": { "version": "5.0.2", @@ -2056,8 +2051,7 @@ "get-func-name": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", - "dev": true + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=" }, "get-stdin": { "version": "5.0.1", @@ -2168,14 +2162,12 @@ "graceful-readlink": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", - "dev": true + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" }, "growl": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", - "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", - "dev": true + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=" }, "handlebars": { "version": "4.0.10", @@ -2226,8 +2218,7 @@ "has-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", - "dev": true + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" }, "hash-base": { "version": "2.0.2", @@ -2825,8 +2816,7 @@ "json3": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", - "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", - "dev": true + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" }, "jsonfile": { "version": "2.4.0", @@ -2904,6 +2894,14 @@ "is-buffer": "1.1.5" } }, + "kvplus-files": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/kvplus-files/-/kvplus-files-0.0.4.tgz", + "integrity": "sha1-bxqmr2sKaThO0GQY4YSwnHDqNUE=", + "requires": { + "fs-extra": "2.1.2" + } + }, "labeled-stream-splicer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.0.tgz", @@ -2958,7 +2956,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", - "dev": true, "requires": { "lodash._basecopy": "3.0.1", "lodash.keys": "3.1.2" @@ -2967,32 +2964,27 @@ "lodash._basecopy": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", - "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", - "dev": true + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=" }, "lodash._basecreate": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", - "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", - "dev": true + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=" }, "lodash._getnative": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", - "dev": true + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" }, "lodash._isiterateecall": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", - "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", - "dev": true + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=" }, "lodash.create": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", - "dev": true, "requires": { "lodash._baseassign": "3.2.0", "lodash._basecreate": "3.0.3", @@ -3002,20 +2994,17 @@ "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", - "dev": true + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" }, "lodash.isarray": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", - "dev": true + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" }, "lodash.keys": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", - "dev": true, "requires": { "lodash._getnative": "3.9.1", "lodash.isarguments": "3.1.0", @@ -3155,7 +3144,6 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "dev": true, "requires": { "minimist": "0.0.8" }, @@ -3163,8 +3151,7 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "dev": true + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" } } }, @@ -3172,7 +3159,6 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.0.tgz", "integrity": "sha512-pIU2PJjrPYvYRqVpjXzj76qltO9uBYI7woYAMoxbSefsa+vqAfptjoeevd6bUgwD0mPIO+hv9f7ltvsNreL2PA==", - "dev": true, "requires": { "browser-stdout": "1.3.0", "commander": "2.9.0", @@ -3191,7 +3177,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", - "dev": true, "requires": { "graceful-readlink": "1.0.1" } @@ -3200,7 +3185,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", - "dev": true, "requires": { "fs.realpath": "1.0.0", "inflight": "1.0.6", @@ -3214,7 +3198,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", - "dev": true, "requires": { "has-flag": "1.0.0" } @@ -3351,8 +3334,7 @@ "net": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz", - "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=", - "dev": true + "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=" }, "nock": { "version": "9.0.14", @@ -3389,7 +3371,6 @@ "version": "1.6.4", "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.6.4.tgz", "integrity": "sha1-ea47ym9HJ5HmGNqjOPu2KJJR0tc=", - "dev": true, "requires": { "accepts": "1.3.3", "depd": "1.1.1", @@ -3406,8 +3387,7 @@ "fresh": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz", - "integrity": "sha1-ZR+DjiJCTnVm3hYdg1jKoZn4PU8=", - "dev": true + "integrity": "sha1-ZR+DjiJCTnVm3hYdg1jKoZn4PU8=" } } }, @@ -3415,7 +3395,6 @@ "version": "0.4.2", "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz", "integrity": "sha1-1jkXKewWqDDtWjgEKzFX0tXXJTA=", - "dev": true, "requires": { "asn1": "0.2.3" } @@ -4894,9 +4873,9 @@ } }, "oidc-auth-manager": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.8.1.tgz", - "integrity": "sha1-+Vb+oLonDBE47opdPQpffPWzbdA=", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.9.0.tgz", + "integrity": "sha512-nXgyjjRneRR/0bg/UTeaIVv4MpPscD3octq+xnvyu2kyziDzrW9f5jtqRqPhN+c4J8snamsOj55T33yROFSY/Q==", "requires": { "@trust/oidc-op": "0.3.0", "@trust/oidc-rs": "0.2.1", @@ -4910,635 +4889,23 @@ "valid-url": "1.0.9" }, "dependencies": { - "@trust/jose": { - "version": "0.1.7", - "bundled": true, - "requires": { - "@trust/json-document": "0.1.4", - "@trust/webcrypto": "0.0.2", - "base64url": "2.0.0", - "text-encoding": "0.6.4" - }, - "dependencies": { - "@trust/webcrypto": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.0.2.tgz", - "integrity": "sha1-53xpouYSudOSJRxZZscxaFN+Jmc=", - "requires": { - "base64url": "2.0.0", - "node-rsa": "0.4.2", - "pem-jwk": "1.5.1", - "text-encoding": "0.6.4" - } - } - } - }, - "@trust/json-document": { - "version": "0.1.4", - "bundled": true - }, - "@trust/keychain": { - "version": "0.1.2", - "bundled": true, - "requires": { - "@trust/webcrypto": "0.3.0", - "base64url": "2.0.0" - } - }, - "@trust/keyto": { - "version": "0.3.1", - "bundled": true, - "requires": { - "asn1.js": "4.9.1", - "base64url": "2.0.0", - "elliptic": "6.4.0" - }, - "dependencies": { - "asn1.js": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.1.tgz", - "integrity": "sha1-SLokC0WpKA6UdImQull9IWYX/UA=", - "requires": { - "bn.js": "4.11.8", - "inherits": "2.0.3", - "minimalistic-assert": "1.0.0" - } - }, - "bn.js": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" - } - } - }, - "@trust/oidc-op": { - "version": "0.3.0", - "bundled": true, - "requires": { - "@trust/jose": "0.1.7", - "@trust/json-document": "0.1.4", - "@trust/keychain": "0.1.2", - "@trust/webcrypto": "0.3.0", - "base64url": "2.0.0", - "pem-jwk": "1.5.1", - "qs": "6.5.0" - } - }, - "@trust/oidc-rp": { - "version": "0.4.1", - "bundled": true, - "requires": { - "@trust/jose": "0.1.7", - "@trust/json-document": "0.1.4", - "@trust/webcrypto": "0.3.0", - "base64url": "2.0.0", - "node-fetch": "1.7.2", - "text-encoding": "0.6.4", - "urlutils": "0.0.3" - } - }, - "@trust/oidc-rs": { - "version": "0.2.1", - "bundled": true, - "requires": { - "@trust/jose": "0.1.7", - "node-fetch": "1.7.2" - } - }, - "@trust/webcrypto": { - "version": "0.3.0", - "bundled": true, - "requires": { - "@trust/keyto": "0.3.1", - "base64url": "2.0.0", - "node-rsa": "0.4.2", - "text-encoding": "0.6.4" - } - }, - "accepts": { - "version": "1.3.3", - "bundled": true, - "requires": { - "mime-types": "2.1.16", - "negotiator": "0.6.1" - } - }, - "asn1": { - "version": "0.2.3", - "bundled": true - }, - "asn1.js": { - "version": "1.0.3", - "bundled": true, - "requires": { - "bn.js": "1.3.0", - "inherits": "2.0.3", - "minimalistic-assert": "1.0.0" - } - }, - "assertion-error": { - "version": "1.0.2", - "bundled": true - }, - "balanced-match": { - "version": "1.0.0", - "bundled": true - }, - "base64url": { - "version": "2.0.0", - "bundled": true - }, - "bcryptjs": { - "version": "2.4.3", - "bundled": true - }, - "bn.js": { - "version": "1.3.0", - "bundled": true, - "optional": true - }, - "brace-expansion": { - "version": "1.1.8", - "bundled": true, - "requires": { - "balanced-match": "1.0.0", - "concat-map": "0.0.1" - } - }, - "brorand": { - "version": "1.1.0", - "bundled": true - }, - "browser-stdout": { - "version": "1.3.0", - "bundled": true - }, - "chai": { - "version": "4.1.1", - "bundled": true, - "requires": { - "assertion-error": "1.0.2", - "check-error": "1.0.2", - "deep-eql": "2.0.2", - "get-func-name": "2.0.0", - "pathval": "1.1.0", - "type-detect": "4.0.3" - } - }, - "check-error": { - "version": "1.0.2", - "bundled": true - }, - "commander": { - "version": "2.9.0", - "bundled": true, - "requires": { - "graceful-readlink": "1.0.1" - } - }, - "concat-map": { - "version": "0.0.1", - "bundled": true - }, - "debug": { - "version": "2.6.8", - "bundled": true, - "requires": { - "ms": "2.0.0" - } - }, - "deep-eql": { - "version": "2.0.2", - "bundled": true, - "requires": { - "type-detect": "3.0.0" - }, - "dependencies": { - "type-detect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-3.0.0.tgz", - "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=" - } - } - }, - "depd": { - "version": "1.1.1", - "bundled": true - }, - "diff": { - "version": "3.2.0", - "bundled": true - }, - "elliptic": { - "version": "6.4.0", - "bundled": true, - "requires": { - "bn.js": "4.11.8", - "brorand": "1.1.0", - "hash.js": "1.1.3", - "hmac-drbg": "1.0.1", - "inherits": "2.0.3", - "minimalistic-assert": "1.0.0", - "minimalistic-crypto-utils": "1.0.1" - }, - "dependencies": { - "bn.js": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" - } - } - }, - "encoding": { - "version": "0.1.12", - "bundled": true, - "requires": { - "iconv-lite": "0.4.18" - } - }, - "escape-string-regexp": { - "version": "1.0.5", - "bundled": true - }, - "fresh": { - "version": "0.3.0", - "bundled": true - }, "fs-extra": { "version": "4.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.1.tgz", + "integrity": "sha1-f8DGyJV/mD9X8waiTlud3Y0N2IA=", "requires": { "graceful-fs": "4.1.11", "jsonfile": "3.0.1", "universalify": "0.1.1" } }, - "fs.realpath": { - "version": "1.0.0", - "bundled": true - }, - "get-func-name": { - "version": "2.0.0", - "bundled": true - }, - "glob": { - "version": "7.1.1", - "bundled": true, - "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.4", - "once": "1.4.0", - "path-is-absolute": "1.0.1" - } - }, - "graceful-fs": { - "version": "4.1.11", - "bundled": true - }, - "graceful-readlink": { - "version": "1.0.1", - "bundled": true - }, - "growl": { - "version": "1.9.2", - "bundled": true - }, - "has-flag": { - "version": "1.0.0", - "bundled": true - }, - "hash.js": { - "version": "1.1.3", - "bundled": true, - "requires": { - "inherits": "2.0.3", - "minimalistic-assert": "1.0.0" - } - }, - "hmac-drbg": { - "version": "1.0.1", - "bundled": true, - "requires": { - "hash.js": "1.1.3", - "minimalistic-assert": "1.0.0", - "minimalistic-crypto-utils": "1.0.1" - } - }, - "iconv-lite": { - "version": "0.4.18", - "bundled": true - }, - "inflight": { - "version": "1.0.6", - "bundled": true, - "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" - } - }, - "inherits": { - "version": "2.0.3", - "bundled": true - }, - "is-stream": { - "version": "1.1.0", - "bundled": true - }, - "json3": { - "version": "3.3.2", - "bundled": true - }, "jsonfile": { "version": "3.0.1", - "bundled": true, + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-3.0.1.tgz", + "integrity": "sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=", "requires": { "graceful-fs": "4.1.11" } - }, - "kvplus-files": { - "version": "0.0.4", - "bundled": true, - "requires": { - "fs-extra": "2.1.2" - }, - "dependencies": { - "fs-extra": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz", - "integrity": "sha1-BGxwFjzvmq1GsOSn+kZ/si1x3jU=", - "requires": { - "graceful-fs": "4.1.11", - "jsonfile": "2.4.0" - } - }, - "jsonfile": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", - "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", - "requires": { - "graceful-fs": "4.1.11" - } - } - } - }, - "li": { - "version": "1.2.1", - "bundled": true - }, - "lodash._baseassign": { - "version": "3.2.0", - "bundled": true, - "requires": { - "lodash._basecopy": "3.0.1", - "lodash.keys": "3.1.2" - } - }, - "lodash._basecopy": { - "version": "3.0.1", - "bundled": true - }, - "lodash._basecreate": { - "version": "3.0.3", - "bundled": true - }, - "lodash._getnative": { - "version": "3.9.1", - "bundled": true - }, - "lodash._isiterateecall": { - "version": "3.0.9", - "bundled": true - }, - "lodash.create": { - "version": "3.1.1", - "bundled": true, - "requires": { - "lodash._baseassign": "3.2.0", - "lodash._basecreate": "3.0.3", - "lodash._isiterateecall": "3.0.9" - } - }, - "lodash.isarguments": { - "version": "3.1.0", - "bundled": true - }, - "lodash.isarray": { - "version": "3.0.4", - "bundled": true - }, - "lodash.keys": { - "version": "3.1.2", - "bundled": true, - "requires": { - "lodash._getnative": "3.9.1", - "lodash.isarguments": "3.1.0", - "lodash.isarray": "3.0.4" - } - }, - "media-typer": { - "version": "0.3.0", - "bundled": true - }, - "merge-descriptors": { - "version": "1.0.1", - "bundled": true - }, - "methods": { - "version": "1.1.2", - "bundled": true - }, - "mime": { - "version": "1.3.6", - "bundled": true - }, - "mime-db": { - "version": "1.29.0", - "bundled": true - }, - "mime-types": { - "version": "2.1.16", - "bundled": true, - "requires": { - "mime-db": "1.29.0" - } - }, - "minimalistic-assert": { - "version": "1.0.0", - "bundled": true - }, - "minimalistic-crypto-utils": { - "version": "1.0.1", - "bundled": true - }, - "minimatch": { - "version": "3.0.4", - "bundled": true, - "requires": { - "brace-expansion": "1.1.8" - } - }, - "minimist": { - "version": "0.0.8", - "bundled": true - }, - "mkdirp": { - "version": "0.5.1", - "bundled": true, - "requires": { - "minimist": "0.0.8" - } - }, - "mocha": { - "version": "3.5.0", - "bundled": true, - "requires": { - "browser-stdout": "1.3.0", - "commander": "2.9.0", - "debug": "2.6.8", - "diff": "3.2.0", - "escape-string-regexp": "1.0.5", - "glob": "7.1.1", - "growl": "1.9.2", - "json3": "3.3.2", - "lodash.create": "3.1.1", - "mkdirp": "0.5.1", - "supports-color": "3.1.2" - } - }, - "ms": { - "version": "2.0.0", - "bundled": true - }, - "negotiator": { - "version": "0.6.1", - "bundled": true - }, - "net": { - "version": "1.0.2", - "bundled": true - }, - "node-fetch": { - "version": "1.7.2", - "bundled": true, - "requires": { - "encoding": "0.1.12", - "is-stream": "1.1.0" - } - }, - "node-mocks-http": { - "version": "1.6.4", - "bundled": true, - "requires": { - "accepts": "1.3.3", - "depd": "1.1.1", - "fresh": "0.3.0", - "merge-descriptors": "1.0.1", - "methods": "1.1.2", - "mime": "1.3.6", - "net": "1.0.2", - "parseurl": "1.3.1", - "range-parser": "1.2.0", - "type-is": "1.6.15" - } - }, - "node-rsa": { - "version": "0.4.2", - "bundled": true, - "requires": { - "asn1": "0.2.3" - } - }, - "once": { - "version": "1.4.0", - "bundled": true, - "requires": { - "wrappy": "1.0.2" - } - }, - "parseurl": { - "version": "1.3.1", - "bundled": true - }, - "path-is-absolute": { - "version": "1.0.1", - "bundled": true - }, - "pathval": { - "version": "1.1.0", - "bundled": true - }, - "pem-jwk": { - "version": "1.5.1", - "bundled": true, - "requires": { - "asn1.js": "1.0.3" - } - }, - "qs": { - "version": "6.5.0", - "bundled": true - }, - "range-parser": { - "version": "1.2.0", - "bundled": true - }, - "solid-multi-rp-client": { - "version": "0.2.0", - "bundled": true, - "requires": { - "@trust/oidc-rp": "0.4.1", - "kvplus-files": "0.0.4" - } - }, - "supports-color": { - "version": "3.1.2", - "bundled": true, - "requires": { - "has-flag": "1.0.0" - } - }, - "text-encoding": { - "version": "0.6.4", - "bundled": true - }, - "type-detect": { - "version": "4.0.3", - "bundled": true - }, - "type-is": { - "version": "1.6.15", - "bundled": true, - "requires": { - "media-typer": "0.3.0", - "mime-types": "2.1.16" - } - }, - "universalify": { - "version": "0.1.1", - "bundled": true - }, - "URIjs": { - "version": "1.16.1", - "bundled": true - }, - "urlutils": { - "version": "0.0.3", - "bundled": true, - "requires": { - "chai": "4.1.1", - "mocha": "3.5.0", - "URIjs": "1.16.1" - } - }, - "valid-url": { - "version": "1.0.9", - "bundled": true - }, - "wrappy": { - "version": "1.0.2", - "bundled": true } } }, @@ -5741,8 +5108,7 @@ "pathval": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", - "dev": true + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=" }, "pbkdf2": { "version": "3.0.13", @@ -5760,7 +5126,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/pem-jwk/-/pem-jwk-1.5.1.tgz", "integrity": "sha1-eoY3/S9nqCflfAxC4cI8P9Us+wE=", - "dev": true, "requires": { "asn1.js": "1.0.3" } @@ -6411,6 +5776,15 @@ "@trust/oidc-rp": "0.4.1" } }, + "solid-multi-rp-client": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.0.tgz", + "integrity": "sha1-5T+356YcC1lVEt67S+B3EhUEJjU=", + "requires": { + "@trust/oidc-rp": "0.4.1", + "kvplus-files": "0.0.4" + } + }, "solid-namespace": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/solid-namespace/-/solid-namespace-0.1.0.tgz", @@ -6868,8 +6242,7 @@ "text-encoding": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", - "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", - "dev": true + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=" }, "text-table": { "version": "0.2.0", @@ -6992,8 +6365,7 @@ "type-detect": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.3.tgz", - "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=", - "dev": true + "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=" }, "type-is": { "version": "1.6.15", @@ -7068,6 +6440,11 @@ "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", "dev": true }, + "universalify": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz", + "integrity": "sha1-+nG63UQ3r0wUiEHjs7Fl+enlkLc=" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7076,8 +6453,7 @@ "URIjs": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/URIjs/-/URIjs-1.16.1.tgz", - "integrity": "sha1-7evGeLi3SyawXStIHhI4P1rgS4s=", - "dev": true + "integrity": "sha1-7evGeLi3SyawXStIHhI4P1rgS4s=" }, "url": { "version": "0.11.0", @@ -7099,7 +6475,6 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/urlutils/-/urlutils-0.0.3.tgz", "integrity": "sha1-aw9e2ibjY1Jc7hpnkJippO/nrd4=", - "dev": true, "requires": { "chai": "4.1.1", "mocha": "3.5.0", @@ -7110,7 +6485,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.1.tgz", "integrity": "sha1-ZuISeebzxkFf+CMYeCJ5AOIXGzk=", - "dev": true, "requires": { "assertion-error": "1.0.2", "check-error": "1.0.2", diff --git a/package.json b/package.json index 31e35034e..74b6402b8 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.8.1", + "oidc-auth-manager": "^0.9.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", diff --git a/test/integration/capability-discovery-test.js b/test/integration/capability-discovery-test.js index 8f9f4dbe3..0bbf13db9 100644 --- a/test/integration/capability-discovery-test.js +++ b/test/integration/capability-discovery-test.js @@ -96,9 +96,9 @@ describe('API', () => { .expect(204, done) }) - it('should return the oidc.provider Link header', (done) => { + it('should return the http://openid.net/specs/connect/1.0/issuer Link rel header', (done) => { alice.options('/') - .expect('Link', /<https:\/\/localhost:5000>; rel="oidc.provider"/) + .expect('Link', /<https:\/\/localhost:5000>; rel="http:\/\/openid\.net\/specs\/connect\/1\.0\/issuer"/) .expect(204, done) }) }) From dbffa3da54bf441eb4502e2aa7b9f24dbbcb11e0 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 15 Aug 2017 14:55:19 -0400 Subject: [PATCH 130/178] Verify webid provider when extracting webid from claim --- lib/api/authn/webid-oidc.js | 14 +++++++++----- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 0ba1ae2c8..b6bf8bbca 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -40,11 +40,15 @@ function initialize (app, argv) { // Expose session.userId app.use('/', (req, res, next) => { - const userId = oidc.webIdFromClaims(req.claims) - if (userId) { - req.session.userId = userId - } - next() + oidc.webIdFromClaims(req.claims) + .then(webId => { + if (webId) { + req.session.userId = webId + } + + next() + }) + .catch(next) }) } diff --git a/package-lock.json b/package-lock.json index 84cd2a14d..9df2253e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4873,9 +4873,9 @@ } }, "oidc-auth-manager": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.9.0.tgz", - "integrity": "sha512-nXgyjjRneRR/0bg/UTeaIVv4MpPscD3octq+xnvyu2kyziDzrW9f5jtqRqPhN+c4J8snamsOj55T33yROFSY/Q==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.10.0.tgz", + "integrity": "sha512-nUASN8khRHMJ4BOYTO7fypfc2GuXd+6aDM8/WMEeyJQJakOPkIFdNV4wBXMaAlCeumWEvoEJI/BhrcZe1L0UMQ==", "requires": { "@trust/oidc-op": "0.3.0", "@trust/oidc-rs": "0.2.1", diff --git a/package.json b/package.json index 74b6402b8..9aab41a9b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.9.0", + "oidc-auth-manager": "^0.10.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", From 9af2c51a3314ac7435ce8f6558a454304b76411c Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 15 Aug 2017 16:49:25 -0400 Subject: [PATCH 131/178] Remove deprecated solid:inbox term from account template --- default-templates/new-account/profile/card | 1 - 1 file changed, 1 deletion(-) diff --git a/default-templates/new-account/profile/card b/default-templates/new-account/profile/card index b9e138ccf..ce337b0a4 100644 --- a/default-templates/new-account/profile/card +++ b/default-templates/new-account/profile/card @@ -18,7 +18,6 @@ solid:account </> ; # link to the account uri pim:storage </> ; # root storage - solid:inbox </inbox/> ; ldp:inbox </inbox/> ; pim:preferencesFile </settings/prefs.ttl> ; # private settings/preferences From 4bcd7fc6079bd91ae705ccd490d9be8e8c008eee Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 16 Aug 2017 11:36:16 -0400 Subject: [PATCH 132/178] Cache APT packages on Travis CI. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 86ae2b4cb..1661e852c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,5 +19,6 @@ addons: - nicola.localhost cache: + apt: true directories: - node_modules From bf83152ae794bfb680ba06484018fb3381e13165 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 16 Aug 2017 14:14:57 -0400 Subject: [PATCH 133/178] Correct certificate-header flag name. --- bin/lib/options.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/lib/options.js b/bin/lib/options.js index 0cae74865..ce88e642d 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -76,7 +76,7 @@ module.exports = [ } }, { - name: 'certificateHeader', + name: 'certificate-header', question: 'Accept client certificates through this HTTP header (for reverse proxies)', default: '', prompt: false From c734cf66e56c3de89ad7a5fc800e9cba436aa53d Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 16 Aug 2017 14:21:06 -0400 Subject: [PATCH 134/178] Add --no-reject-unauthorized flag. --- bin/lib/options.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bin/lib/options.js b/bin/lib/options.js index ce88e642d..222f90014 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -115,6 +115,13 @@ module.exports = [ validate: validPath, prompt: true }, + { + name: 'no-reject-unauthorized', + help: 'Accepts clients with invalid certificates (set for testing WebID-TLS)', + flag: true, + default: false, + prompt: false + }, { name: 'idp', help: 'Enable multi-user mode (users can sign up for accounts)', From b3b755b2295a0c17fb080282ac505383def308ff Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 16 Aug 2017 14:47:47 -0400 Subject: [PATCH 135/178] Disable rejectUnauthorized on solid-test. --- README.md | 2 +- bin/solid-test | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60ecdff04..97b5239e7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ $ solid start --root path/to/folder --port 8443 --ssl-key path/to/ssl-key.pem -- ### Running in development environments -Solid requires SSL certificates to be valid, so you cannot use self-signed certificates. To switch off this security feature in development environments, you can use the `bin/solid-test` executable, which unsets the `NODE_TLS_REJECT_UNAUTHORIZED` flag. If you want to test WebID-TLS authentication with self-signed certificates, additionally set `"rejectUnauthorized": false` in `config.json`. +Solid requires SSL certificates to be valid, so you cannot use self-signed certificates. To switch off this security feature in development environments, you can use the `bin/solid-test` executable, which unsets the `NODE_TLS_REJECT_UNAUTHORIZED` flag and sets the `rejectUnauthorized` option. ##### How do I get an SSL key and certificate? You need an SSL certificate from a _certificate authority_, such as your domain provider or [Let's Encrypt!](https://letsencrypt.org/getting-started/). diff --git a/bin/solid-test b/bin/solid-test index cb07db190..aad096e73 100755 --- a/bin/solid-test +++ b/bin/solid-test @@ -1,2 +1,12 @@ #!/usr/bin/env bash -NODE_TLS_REJECT_UNAUTHORIZED=0 exec `dirname "$0"`/solid $@ +COMMAND=$1 +ADD_FLAGS= +shift + +# Disable rejectUnauthorized when starting the server +if [ "$COMMAND" == "start" ]; then + ADD_FLAGS="--no-reject-unauthorized" + export NODE_TLS_REJECT_UNAUTHORIZED=0 +fi + +exec `dirname "$0"`/solid $COMMAND $ADD_FLAGS $@ From 5fe555dfb1bc2149318169a2f4f7f7c35b87f303 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 16 Aug 2017 12:58:41 -0400 Subject: [PATCH 136/178] Display error messages on Select Provider page --- default-views/auth/select-provider.hbs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/default-views/auth/select-provider.hbs b/default-views/auth/select-provider.hbs index 2c7fa1382..a158b3f14 100644 --- a/default-views/auth/select-provider.hbs +++ b/default-views/auth/select-provider.hbs @@ -11,6 +11,13 @@ <div> <h2>Select Provider</h2> </div> + {{#if error}} + <div class="row"> + <div class="col-md-12"> + <p class="text-danger"><strong>{{error}}</strong></p> + </div> + </div> + {{/if}} </div> <div class="container"> <form method="post" action="/api/auth/select-provider"> From 560c57084af8856f2a04b81cf175784aba78a8d9 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 16 Aug 2017 14:47:27 -0400 Subject: [PATCH 137/178] Allow login via TLS with externally hosted WebIDs --- lib/models/account-manager.js | 9 +++--- lib/models/authenticator.js | 28 ++++++++++++++--- package-lock.json | 49 +++++++++++++++++++++-------- package.json | 2 +- test/unit/tls-authenticator-test.js | 32 +++++++++++++++---- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 8a6268668..ace78abc9 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -345,18 +345,19 @@ class AccountManager { username: userData.username, email: userData.email, name: userData.name, + externalWebId: userData.externalWebId, webId: userData.webid || userData.webId || this.accountWebIdFor(userData.username) } - if (userConfig.webId && !userConfig.username) { - userConfig.username = this.usernameFromWebId(userConfig.webId) - } - if (!userConfig.webId && !userConfig.username) { throw new Error('Username or web id is required') } + if (userConfig.webId && !userConfig.username) { + userConfig.username = this.usernameFromWebId(userConfig.webId) + } + return UserAccount.from(userConfig) } diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index c06e18673..d205328b6 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -3,6 +3,8 @@ const debug = require('./../debug').authentication const validUrl = require('valid-url') const webid = require('webid/tls') +const provider = require('oidc-auth-manager/src/preferred-provider') +const { domainMatches } = require('oidc-auth-manager/src/oidc-manager') /** * Abstract Authenticator class, representing a local login strategy. @@ -293,6 +295,10 @@ class TlsAuthenticator extends Authenticator { webid.verify(certificate, callback) } + discoverProviderFor (webId) { + return provider.discoverProviderFor(webId) + } + /** * Ensures that the extracted WebID URI is hosted on this server. If it is, * returns a UserAccount instance for that WebID, throws an error otherwise. @@ -304,13 +310,27 @@ class TlsAuthenticator extends Authenticator { * @return {UserAccount} */ ensureLocalUser (webId) { - if (this.accountManager.externalAccount(webId)) { - debug(`WebID URI ${JSON.stringify(webId)} is not a local account`) + const serverUri = this.accountManager.host.serverUri - throw new Error('Cannot login: Selected Web ID is not hosted on this server') + // if (this.accountManager.externalAccount(webId)) { + if (domainMatches(serverUri, webId)) { + // This is a locally hosted Web ID + return Promise.resolve(this.accountManager.userAccountFrom({ webId })) } - return this.accountManager.userAccountFrom({ webId }) + debug(`WebID URI ${JSON.stringify(webId)} is not a local account, verifying preferred provider`) + + return this.discoverProviderFor(webId) + .then(preferredProvider => { + debug(`Preferred provider for ${webId} is ${preferredProvider}`) + + if (preferredProvider === serverUri) { // everything checks out + return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) + } + + throw new Error(`This server is not the preferred provider for Web ID ${webId}`) + }) + // return Promise.reject(new Error('Cannot login: Selected Web ID is not hosted on this server')) } } diff --git a/package-lock.json b/package-lock.json index 9df2253e9..6df92ea1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4873,9 +4873,9 @@ } }, "oidc-auth-manager": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.10.0.tgz", - "integrity": "sha512-nUASN8khRHMJ4BOYTO7fypfc2GuXd+6aDM8/WMEeyJQJakOPkIFdNV4wBXMaAlCeumWEvoEJI/BhrcZe1L0UMQ==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.11.1.tgz", + "integrity": "sha512-P83EZ/muMqwyaXvGbnTTQSzx4gPaKhhjiGH5BOSTP2Kb0mlDT1QEOMKYkDS1frUi9ZkZBppVH7zKW4dyU3rAig==", "requires": { "@trust/oidc-op": "0.3.0", "@trust/oidc-rs": "0.2.1", @@ -4885,10 +4885,16 @@ "li": "1.2.1", "node-fetch": "1.7.2", "node-mocks-http": "1.6.4", - "solid-multi-rp-client": "0.2.0", + "rdflib": "0.16.2", + "solid-multi-rp-client": "0.2.1", "valid-url": "1.0.9" }, "dependencies": { + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=" + }, "fs-extra": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.1.tgz", @@ -4906,6 +4912,32 @@ "requires": { "graceful-fs": "4.1.11" } + }, + "rdflib": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.16.2.tgz", + "integrity": "sha1-IAzOwaZxAQIVr5bp5s187eKyGrU=", + "requires": { + "async": "0.9.2", + "jsonld": "0.4.12", + "n3": "0.4.5", + "node-fetch": "1.7.2", + "xmldom": "0.1.27" + } + }, + "solid-multi-rp-client": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.1.tgz", + "integrity": "sha1-NGinUYjv6KpfTE5+wUQ6cbgy0AM=", + "requires": { + "@trust/oidc-rp": "0.4.1", + "kvplus-files": "0.0.4" + } + }, + "xmldom": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", + "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" } } }, @@ -5776,15 +5808,6 @@ "@trust/oidc-rp": "0.4.1" } }, - "solid-multi-rp-client": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.0.tgz", - "integrity": "sha1-5T+356YcC1lVEt67S+B3EhUEJjU=", - "requires": { - "@trust/oidc-rp": "0.4.1", - "kvplus-files": "0.0.4" - } - }, "solid-namespace": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/solid-namespace/-/solid-namespace-0.1.0.tgz", diff --git a/package.json b/package.json index 9aab41a9b..2a5a2ba8b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.10.0", + "oidc-auth-manager": "^0.11.1", "oidc-op-express": "^0.0.3", "rdflib": "^0.15.0", "recursive-readdir": "^2.1.0", diff --git a/test/unit/tls-authenticator-test.js b/test/unit/tls-authenticator-test.js index 0ae6a3d2e..8b48bb742 100644 --- a/test/unit/tls-authenticator-test.js +++ b/test/unit/tls-authenticator-test.js @@ -134,13 +134,18 @@ describe('TlsAuthenticator', () => { }) describe('ensureLocalUser()', () => { - it('should throw an error if the user is not local to this server', () => { + it('should throw an error if external user and this server not the preferred provider', done => { let tlsAuth = new TlsAuthenticator({ accountManager }) let externalWebId = 'https://alice.someothersite.com#me' - expect(() => tlsAuth.ensureLocalUser(externalWebId)) - .to.throw(/Cannot login: Selected Web ID is not hosted on this server/) + tlsAuth.discoverProviderFor = sinon.stub().resolves('https://another-provider.com') + + tlsAuth.ensureLocalUser(externalWebId) + .catch(err => { + expect(err.message).to.match(/This server is not the preferred provider for Web ID https:\/\/alice.someothersite.com#me/) + done() + }) }) it('should return a user instance if the webid is local', () => { @@ -148,10 +153,25 @@ describe('TlsAuthenticator', () => { let webId = 'https://alice.example.com/#me' - let user = tlsAuth.ensureLocalUser(webId) + return tlsAuth.ensureLocalUser(webId) + .then(user => { + expect(user.username).to.equal('alice') + expect(user.webId).to.equal(webId) + }) + }) + + it('should return a user instance if external user and this server is preferred provider', () => { + let tlsAuth = new TlsAuthenticator({ accountManager }) + + let externalWebId = 'https://alice.someothersite.com#me' + + tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') - expect(user.username).to.equal('alice') - expect(user.webId).to.equal(webId) + tlsAuth.ensureLocalUser(externalWebId) + .then(user => { + expect(user.username).to.equal(externalWebId) + expect(user.webId).to.equal(externalWebId) + }) }) }) From 1782ba3ee8a3c5de5a3a310fe0be264937462821 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 16 Aug 2017 15:08:22 -0400 Subject: [PATCH 138/178] TlsAuthenticator - minor refactor/cleanup --- lib/models/account-manager.js | 16 ++++------------ lib/models/authenticator.js | 10 ++++------ test/unit/account-manager-test.js | 22 ---------------------- test/unit/tls-authenticator-test.js | 6 +++--- 4 files changed, 11 insertions(+), 43 deletions(-) diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index ace78abc9..8bfc94e0b 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -350,11 +350,11 @@ class AccountManager { this.accountWebIdFor(userData.username) } - if (!userConfig.webId && !userConfig.username) { - throw new Error('Username or web id is required') - } + if (!userConfig.username) { + if (!userConfig.webId) { + throw new Error('Username or web id is required') + } - if (userConfig.webId && !userConfig.username) { userConfig.username = this.usernameFromWebId(userConfig.webId) } @@ -393,14 +393,6 @@ class AccountManager { }) } - externalAccount (webId) { - let webIdHostname = url.parse(webId).hostname - - let serverHostname = this.host.hostname - - return !webIdHostname.endsWith(serverHostname) - } - /** * Generates an expiring one-time-use token for password reset purposes * (the user's Web ID is saved in the token service). diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index d205328b6..44a7d4d6b 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -312,7 +312,6 @@ class TlsAuthenticator extends Authenticator { ensureLocalUser (webId) { const serverUri = this.accountManager.host.serverUri - // if (this.accountManager.externalAccount(webId)) { if (domainMatches(serverUri, webId)) { // This is a locally hosted Web ID return Promise.resolve(this.accountManager.userAccountFrom({ webId })) @@ -321,16 +320,15 @@ class TlsAuthenticator extends Authenticator { debug(`WebID URI ${JSON.stringify(webId)} is not a local account, verifying preferred provider`) return this.discoverProviderFor(webId) - .then(preferredProvider => { - debug(`Preferred provider for ${webId} is ${preferredProvider}`) + .then(authorizedProvider => { + debug(`Authorized provider for ${webId} is ${authorizedProvider}`) - if (preferredProvider === serverUri) { // everything checks out + if (authorizedProvider === serverUri) { // everything checks out return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) } - throw new Error(`This server is not the preferred provider for Web ID ${webId}`) + throw new Error(`This server is not the authorized provider for Web ID ${webId}`) }) - // return Promise.reject(new Error('Cannot login: Selected Web ID is not hosted on this server')) } } diff --git a/test/unit/account-manager-test.js b/test/unit/account-manager-test.js index fc7f57584..1bb0453bc 100644 --- a/test/unit/account-manager-test.js +++ b/test/unit/account-manager-test.js @@ -455,26 +455,4 @@ describe('AccountManager', () => { }) }) }) - - describe('externalAccount()', () => { - it('should return true if account is a subdomain of the local server url', () => { - let options = { host } - - let accountManager = AccountManager.from(options) - - let webId = 'https://alice.example.com/#me' - - expect(accountManager.externalAccount(webId)).to.be.false() - }) - - it('should return false if account does not match the local server url', () => { - let options = { host } - - let accountManager = AccountManager.from(options) - - let webId = 'https://alice.databox.me/#me' - - expect(accountManager.externalAccount(webId)).to.be.true() - }) - }) }) diff --git a/test/unit/tls-authenticator-test.js b/test/unit/tls-authenticator-test.js index 8b48bb742..11e351c59 100644 --- a/test/unit/tls-authenticator-test.js +++ b/test/unit/tls-authenticator-test.js @@ -134,7 +134,7 @@ describe('TlsAuthenticator', () => { }) describe('ensureLocalUser()', () => { - it('should throw an error if external user and this server not the preferred provider', done => { + it('should throw an error if external user and this server not the authorized provider', done => { let tlsAuth = new TlsAuthenticator({ accountManager }) let externalWebId = 'https://alice.someothersite.com#me' @@ -143,7 +143,7 @@ describe('TlsAuthenticator', () => { tlsAuth.ensureLocalUser(externalWebId) .catch(err => { - expect(err.message).to.match(/This server is not the preferred provider for Web ID https:\/\/alice.someothersite.com#me/) + expect(err.message).to.match(/This server is not the authorized provider for Web ID https:\/\/alice.someothersite.com#me/) done() }) }) @@ -160,7 +160,7 @@ describe('TlsAuthenticator', () => { }) }) - it('should return a user instance if external user and this server is preferred provider', () => { + it('should return a user instance if external user and this server is authorized provider', () => { let tlsAuth = new TlsAuthenticator({ accountManager }) let externalWebId = 'https://alice.someothersite.com#me' From fb7235d746d2e7f7810c0a462ff9b0884033d9ff Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Thu, 17 Aug 2017 13:17:30 -0400 Subject: [PATCH 139/178] Add link to issuer discovery spec on error --- lib/models/authenticator.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index 44a7d4d6b..08f65dbee 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -327,7 +327,8 @@ class TlsAuthenticator extends Authenticator { return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) } - throw new Error(`This server is not the authorized provider for Web ID ${webId}`) + throw new Error(`This server is not the authorized provider for Web ID ${webId}. + See https://github.com/solid/webid-oidc-spec#authorized-oidc-issuer-discovery`) }) } } From eed6c40230667d93f567e0aeea8696ea679f2537 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 17 Aug 2017 23:08:28 -0400 Subject: [PATCH 140/178] Convert checkAccess to promise. --- lib/acl-checker.js | 46 ++++++++++++++++------------------- test/unit/acl-checker-test.js | 37 +++++++++------------------- 2 files changed, 33 insertions(+), 50 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 8cda9d396..83bcb5c1f 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -46,9 +46,8 @@ class ACLChecker { resource, // The resource we want to access accessType, // accessTo or defaultForNew acl, // The current Acl file! - (err) => { return next(!err || err) }, options - ) + ).then(next, next) }) }, function handleNoAccess (err) { @@ -87,17 +86,14 @@ class ACLChecker { * @param resource {String} URI of the resource being accessed * @param accessType {String} One of `accessTo`, or `default` * @param acl {String} URI of this current .acl resource - * @param callback {Function} * @param options {Object} Options hashmap * @param [options.origin] Request's `Origin:` header * @param [options.host] Request's host URI (with protocol) */ - checkAccess (graph, user, mode, resource, accessType, acl, callback, - options = {}) { - const debug = this.debug + checkAccess (graph, user, mode, resource, accessType, acl, options = {}) { if (!graph || graph.length === 0) { debug('ACL ' + acl + ' is empty') - return callback(new Error('No policy found - empty ACL')) + return Promise.reject(new Error('No policy found - empty ACL')) } let isContainer = accessType.startsWith('default') let aclOptions = { @@ -107,26 +103,26 @@ class ACLChecker { origin: options.origin, rdf: rdf, strictOrigin: this.strictOrigin, - isAcl: (uri) => { return this.isAcl(uri) }, - aclUrlFor: (uri) => { return this.aclUrlFor(uri) } + isAcl: uri => this.isAcl(uri), + aclUrlFor: uri => this.aclUrlFor(uri) } let acls = new PermissionSet(resource, acl, isContainer, aclOptions) - acls.checkAccess(resource, user, mode) - .then(hasAccess => { - if (hasAccess) { - debug(`${mode} access permitted to ${user}`) - return callback() - } else { - debug(`${mode} access NOT permitted to ${user}` + - aclOptions.strictOrigin ? ` and origin ${options.origin}` : '') - return callback(new Error('ACL file found but no matching policy found')) - } - }) - .catch(err => { - debug(`${mode} access denied to ${user}`) - debug(err) - return callback(err) - }) + return acls.checkAccess(resource, user, mode) + .then(hasAccess => { + if (hasAccess) { + this.debug(`${mode} access permitted to ${user}`) + return true + } else { + this.debug(`${mode} access NOT permitted to ${user}` + + aclOptions.strictOrigin ? ` and origin ${options.origin}` : '') + throw new Error('ACL file found but no matching policy found') + } + }) + .catch(err => { + this.debug(`${mode} access denied to ${user}`) + this.debug(err) + throw err + }) } aclUrlFor (uri) { diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.js index a4569e5b8..3626c1daf 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.js @@ -1,13 +1,9 @@ 'use strict' const proxyquire = require('proxyquire') -const chai = require('chai') -const { assert } = chai -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() const debug = require('../../lib/debug').ACL +const chai = require('chai') +const { expect } = chai +chai.use(require('chai-as-promised')) class PermissionSetAlwaysGrant { checkAccess () { @@ -26,7 +22,7 @@ class PermissionSetAlwaysError { } describe('ACLChecker unit test', () => { - it('should callback with null on grant success', done => { + it('should callback with null on grant success', () => { let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetAlwaysGrant } }) @@ -34,13 +30,10 @@ describe('ACLChecker unit test', () => { let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - acl.checkAccess(graph, user, mode, resource, accessType, aclUrl, (err) => { - assert.isUndefined(err, - 'Granted permission should result in an empty callback!') - done() - }) + return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + .to.eventually.be.true }) - it('should callback with error on grant failure', done => { + it('should callback with error on grant failure', () => { let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetNeverGrant } }) @@ -48,13 +41,10 @@ describe('ACLChecker unit test', () => { let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - acl.checkAccess(graph, user, mode, resource, accessType, aclUrl, (err) => { - assert.ok(err instanceof Error, - 'Denied permission should result in an error callback!') - done() - }) + return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + .to.be.rejectedWith('ACL file found but no matching policy found') }) - it('should callback with error on grant error', done => { + it('should callback with error on grant error', () => { let ACLChecker = proxyquire('../../lib/acl-checker', { 'solid-permissions': { PermissionSet: PermissionSetAlwaysError } }) @@ -62,10 +52,7 @@ describe('ACLChecker unit test', () => { let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - acl.checkAccess(graph, user, mode, resource, accessType, aclUrl, (err) => { - assert.ok(err instanceof Error, - 'Error during checkAccess should result in an error callback!') - done() - }) + return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + .to.be.rejectedWith('Error thrown during checkAccess()') }) }) From 27f6b884f0d64a8465425c4080bffcb8e4f916dc Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 17 Aug 2017 23:54:54 -0400 Subject: [PATCH 141/178] Convert can to promise. --- lib/acl-checker.js | 89 ++++++++++++++++++++----------------------- lib/handlers/allow.js | 3 +- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 83bcb5c1f..21caa2388 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -1,10 +1,10 @@ 'use strict' -const async = require('async') const path = require('path') const PermissionSet = require('solid-permissions').PermissionSet const rdf = require('rdflib') const url = require('url') +const HTTPError = require('./http-error') const DEFAULT_ACL_SUFFIX = '.acl' @@ -16,63 +16,58 @@ class ACLChecker { this.suffix = options.suffix || DEFAULT_ACL_SUFFIX } - can (user, mode, resource, callback, options = {}) { + can (user, mode, resource, options = {}) { const debug = this.debug debug('Can ' + (user || 'an agent') + ' ' + mode + ' ' + resource + '?') - var accessType = 'accessTo' - var possibleACLs = ACLChecker.possibleACLs(resource, this.suffix) // If this is an ACL, Control mode must be present for any operations if (this.isAcl(resource)) { mode = 'Control' } - var self = this - async.eachSeries( - possibleACLs, - // Looks for ACL, if found, looks for a rule - function tryAcl (acl, next) { + // Find nearest ACL + let accessType = 'accessTo' + let nearestACL = Promise.reject() + for (const acl of ACLChecker.possibleACLs(resource, this.suffix)) { + nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { debug('Check if acl exist: ' + acl) - // Let's see if there is a file.. - self.fetch(acl, function (err, graph) { - if (err || !graph || graph.length === 0) { - if (err) debug('Error: ' + err) + this.fetch(acl, function (err, graph) { + if (err || !graph || !graph.length) { + if (err) debug(`Error reading ${acl}: ${err}`) accessType = 'defaultForNew' - return next() - } - self.checkAccess( - graph, // The ACL graph - user, // The webId of the user - mode, // Read/Write/Append - resource, // The resource we want to access - accessType, // accessTo or defaultForNew - acl, // The current Acl file! - options - ).then(next, next) - }) - }, - function handleNoAccess (err) { - if (err === false || err === null) { - debug('No ACL resource found - access not allowed') - err = new Error('No Access Control Policy found') - } - if (err === true) { - debug('ACL policy found') - err = null - } - if (err) { - debug('Error: ' + err.message) - if (!user || user.length === 0) { - debug('Authentication required') - err.status = 401 - err.message = 'Access to ' + resource + ' requires authorization' + reject(err) } else { - debug(mode + ' access denied for: ' + user) - err.status = 403 - err.message = 'Access denied for ' + user + resolve({ acl, graph }) } - } - return callback(err) - }) + }) + })) + } + nearestACL = nearestACL.catch(() => { + throw new Error('No ACL resource found') + }) + + // Check the permissions within the ACL + return nearestACL.then(({ acl, graph }) => + this.checkAccess( + graph, // The ACL graph + user, // The webId of the user + mode, // Read/Write/Append + resource, // The resource we want to access + accessType, // accessTo or defaultForNew + acl, // The current Acl file! + options + ) + ) + .then(() => { debug('ACL policy found') }) + .catch(err => { + debug(`Error: ${err.message}`) + if (!user) { + debug('Authentication required') + throw new HTTPError(401, `Access to ${resource} requires authorization`) + } else { + debug(`${mode} access denied for ${user}`) + throw new HTTPError(403, `Access denied for ${user}`) + } + }) } /** diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 6046453a8..d8be7727d 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -37,7 +37,8 @@ function allow (mode) { origin: req.get('origin'), host: req.protocol + '://' + req.get('host') } - return acl.can(req.session.userId, mode, baseUri + reqPath, next, options) + acl.can(req.session.userId, mode, baseUri + reqPath, options) + .then(next, next) }) } } From c7ca72b393b49e66fe3d017125cb5509c40b52d9 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 10:12:35 -0400 Subject: [PATCH 142/178] Move getNearestACL into separate method. --- lib/acl-checker.js | 94 +++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 21caa2388..ee673aca1 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -24,29 +24,9 @@ class ACLChecker { mode = 'Control' } - // Find nearest ACL - let accessType = 'accessTo' - let nearestACL = Promise.reject() - for (const acl of ACLChecker.possibleACLs(resource, this.suffix)) { - nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { - debug('Check if acl exist: ' + acl) - this.fetch(acl, function (err, graph) { - if (err || !graph || !graph.length) { - if (err) debug(`Error reading ${acl}: ${err}`) - accessType = 'defaultForNew' - reject(err) - } else { - resolve({ acl, graph }) - } - }) - })) - } - nearestACL = nearestACL.catch(() => { - throw new Error('No ACL resource found') - }) - - // Check the permissions within the ACL - return nearestACL.then(({ acl, graph }) => + // Check the permissions within the nearest ACL + return this.getNearestACL(resource) + .then(({ acl, graph, accessType }) => this.checkAccess( graph, // The ACL graph user, // The webId of the user @@ -70,6 +50,51 @@ class ACLChecker { }) } + // Gets the ACL that applies to the resource + getNearestACL (uri) { + let accessType = 'accessTo' + let nearestACL = Promise.reject() + for (const acl of this.getPossibleACLs(uri, this.suffix)) { + nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { + this.debug(`Check if ACL exists: ${acl}`) + this.fetch(acl, (err, graph) => { + if (err || !graph || !graph.length) { + if (err) this.debug(`Error reading ${acl}: ${err}`) + accessType = 'defaultForNew' + reject(err) + } else { + resolve({ acl, graph, accessType }) + } + }) + })) + } + return nearestACL.catch(e => { throw new Error('No ACL resource found') }) + } + + // Get all possible ACL paths that apply to the resource + getPossibleACLs (uri, suffix) { + var first = uri.endsWith(suffix) ? uri : uri + suffix + var urls = [first] + var parsedUri = url.parse(uri) + var baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') + + (parsedUri.host || '') + if (baseUrl + '/' === uri) { + return urls + } + + var times = parsedUri.pathname.split('/').length + // TODO: improve temporary solution to stop recursive path walking above root + if (parsedUri.pathname.endsWith('/')) { + times-- + } + + for (var i = 0; i < times - 1; i++) { + uri = path.dirname(uri) + urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix)) + } + return urls + } + /** * Tests whether a graph (parsed .acl resource) allows a given operation * for a given user. Calls the provided callback with `null` if the user @@ -135,29 +160,6 @@ class ACLChecker { return false } } - - static possibleACLs (uri, suffix) { - var first = uri.endsWith(suffix) ? uri : uri + suffix - var urls = [first] - var parsedUri = url.parse(uri) - var baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') + - (parsedUri.host || '') - if (baseUrl + '/' === uri) { - return urls - } - - var times = parsedUri.pathname.split('/').length - // TODO: improve temporary solution to stop recursive path walking above root - if (parsedUri.pathname.endsWith('/')) { - times-- - } - - for (var i = 0; i < times - 1; i++) { - uri = path.dirname(uri) - urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix)) - } - return urls - } } module.exports = ACLChecker From 83da96b4a205ba89c453761d3ea539f6e7c0f07e Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 10:23:48 -0400 Subject: [PATCH 143/178] Move getPermissionSet into separate method. --- lib/acl-checker.js | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index ee673aca1..ac48aa798 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -111,22 +111,9 @@ class ACLChecker { * @param [options.host] Request's host URI (with protocol) */ checkAccess (graph, user, mode, resource, accessType, acl, options = {}) { - if (!graph || graph.length === 0) { - debug('ACL ' + acl + ' is empty') - return Promise.reject(new Error('No policy found - empty ACL')) - } - let isContainer = accessType.startsWith('default') - let aclOptions = { - aclSuffix: this.suffix, - graph: graph, - host: options.host, - origin: options.origin, - rdf: rdf, - strictOrigin: this.strictOrigin, - isAcl: uri => this.isAcl(uri), - aclUrlFor: uri => this.aclUrlFor(uri) - } - let acls = new PermissionSet(resource, acl, isContainer, aclOptions) + const isContainer = accessType.startsWith('default') + const acls = this.getPermissionSet(graph, resource, isContainer, acl, options) + return acls.checkAccess(resource, user, mode) .then(hasAccess => { if (hasAccess) { @@ -134,7 +121,7 @@ class ACLChecker { return true } else { this.debug(`${mode} access NOT permitted to ${user}` + - aclOptions.strictOrigin ? ` and origin ${options.origin}` : '') + this.strictOrigin ? ` and origin ${options.origin}` : '') throw new Error('ACL file found but no matching policy found') } }) @@ -145,6 +132,26 @@ class ACLChecker { }) } + // Gets the permission set for the given resource + getPermissionSet (graph, resource, isContainer, acl, options = {}) { + const debug = this.debug + if (!graph || graph.length === 0) { + debug('ACL ' + acl + ' is empty') + throw new Error('No policy found - empty ACL') + } + const aclOptions = { + aclSuffix: this.suffix, + graph: graph, + host: options.host, + origin: options.origin, + rdf: rdf, + strictOrigin: this.strictOrigin, + isAcl: uri => this.isAcl(uri), + aclUrlFor: uri => this.aclUrlFor(uri) + } + return new PermissionSet(resource, acl, isContainer, aclOptions) + } + aclUrlFor (uri) { if (this.isAcl(uri)) { return uri From 554d19861f1139c0723ff75f6fc2bc759dfac9df Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 10:28:42 -0400 Subject: [PATCH 144/178] Change accessType into isContainer. --- lib/acl-checker.js | 15 +++++++-------- test/unit/acl-checker-test.js | 9 +++------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index ac48aa798..b5f32fad9 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -26,13 +26,13 @@ class ACLChecker { // Check the permissions within the nearest ACL return this.getNearestACL(resource) - .then(({ acl, graph, accessType }) => + .then(({ acl, graph, isContainer }) => this.checkAccess( graph, // The ACL graph user, // The webId of the user mode, // Read/Write/Append resource, // The resource we want to access - accessType, // accessTo or defaultForNew + isContainer, acl, // The current Acl file! options ) @@ -52,7 +52,7 @@ class ACLChecker { // Gets the ACL that applies to the resource getNearestACL (uri) { - let accessType = 'accessTo' + let isContainer = false let nearestACL = Promise.reject() for (const acl of this.getPossibleACLs(uri, this.suffix)) { nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { @@ -60,10 +60,10 @@ class ACLChecker { this.fetch(acl, (err, graph) => { if (err || !graph || !graph.length) { if (err) this.debug(`Error reading ${acl}: ${err}`) - accessType = 'defaultForNew' + isContainer = true reject(err) } else { - resolve({ acl, graph, accessType }) + resolve({ acl, graph, isContainer }) } }) })) @@ -104,14 +104,13 @@ class ACLChecker { * @param user {String} WebID URI of the user accessing the resource * @param mode {String} Access mode, e.g. 'Read', 'Write', etc. * @param resource {String} URI of the resource being accessed - * @param accessType {String} One of `accessTo`, or `default` + * @param isContainer {boolean} * @param acl {String} URI of this current .acl resource * @param options {Object} Options hashmap * @param [options.origin] Request's `Origin:` header * @param [options.host] Request's host URI (with protocol) */ - checkAccess (graph, user, mode, resource, accessType, acl, options = {}) { - const isContainer = accessType.startsWith('default') + checkAccess (graph, user, mode, resource, isContainer, acl, options = {}) { const acls = this.getPermissionSet(graph, resource, isContainer, acl, options) return acls.checkAccess(resource, user, mode) diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.js index 3626c1daf..74c25b63f 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.js @@ -27,10 +27,9 @@ describe('ACLChecker unit test', () => { 'solid-permissions': { PermissionSet: PermissionSetAlwaysGrant } }) let graph = {} - let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) .to.eventually.be.true }) it('should callback with error on grant failure', () => { @@ -38,10 +37,9 @@ describe('ACLChecker unit test', () => { 'solid-permissions': { PermissionSet: PermissionSetNeverGrant } }) let graph = {} - let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) .to.be.rejectedWith('ACL file found but no matching policy found') }) it('should callback with error on grant error', () => { @@ -49,10 +47,9 @@ describe('ACLChecker unit test', () => { 'solid-permissions': { PermissionSet: PermissionSetAlwaysError } }) let graph = {} - let accessType = '' let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, accessType, aclUrl)) + return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) .to.be.rejectedWith('Error thrown during checkAccess()') }) }) From c7230099c4ba9b6e694456a177aede5f92954564 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 10:46:46 -0400 Subject: [PATCH 145/178] Pass permission set to checkAccess. --- lib/acl-checker.js | 42 ++++++++--------------------------- test/unit/acl-checker-test.js | 9 +++++--- 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index b5f32fad9..c090ce813 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -26,17 +26,10 @@ class ACLChecker { // Check the permissions within the nearest ACL return this.getNearestACL(resource) - .then(({ acl, graph, isContainer }) => - this.checkAccess( - graph, // The ACL graph - user, // The webId of the user - mode, // Read/Write/Append - resource, // The resource we want to access - isContainer, - acl, // The current Acl file! - options - ) - ) + .then(nearestAcl => { + const acls = this.getPermissionSet(nearestAcl, resource, options) + return this.checkAccess(acls, user, mode, resource) + }) .then(() => { debug('ACL policy found') }) .catch(err => { debug(`Error: ${err.message}`) @@ -95,32 +88,15 @@ class ACLChecker { return urls } - /** - * Tests whether a graph (parsed .acl resource) allows a given operation - * for a given user. Calls the provided callback with `null` if the user - * has access, otherwise calls it with an error. - * @method checkAccess - * @param graph {Graph} Parsed RDF graph of current .acl resource - * @param user {String} WebID URI of the user accessing the resource - * @param mode {String} Access mode, e.g. 'Read', 'Write', etc. - * @param resource {String} URI of the resource being accessed - * @param isContainer {boolean} - * @param acl {String} URI of this current .acl resource - * @param options {Object} Options hashmap - * @param [options.origin] Request's `Origin:` header - * @param [options.host] Request's host URI (with protocol) - */ - checkAccess (graph, user, mode, resource, isContainer, acl, options = {}) { - const acls = this.getPermissionSet(graph, resource, isContainer, acl, options) - - return acls.checkAccess(resource, user, mode) + // Tests whether the permissions allow a given operation + checkAccess (permissionSet, user, mode, resource) { + return permissionSet.checkAccess(resource, user, mode) .then(hasAccess => { if (hasAccess) { this.debug(`${mode} access permitted to ${user}`) return true } else { - this.debug(`${mode} access NOT permitted to ${user}` + - this.strictOrigin ? ` and origin ${options.origin}` : '') + this.debug(`${mode} access NOT permitted to ${user}`) throw new Error('ACL file found but no matching policy found') } }) @@ -132,7 +108,7 @@ class ACLChecker { } // Gets the permission set for the given resource - getPermissionSet (graph, resource, isContainer, acl, options = {}) { + getPermissionSet ({ acl, graph, isContainer }, resource, options = {}) { const debug = this.debug if (!graph || graph.length === 0) { debug('ACL ' + acl + ' is empty') diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.js index 74c25b63f..2bc67015e 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.js @@ -29,7 +29,8 @@ describe('ACLChecker unit test', () => { let graph = {} let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) + return expect(acl.checkAccess(acls, user, mode, resource)) .to.eventually.be.true }) it('should callback with error on grant failure', () => { @@ -39,7 +40,8 @@ describe('ACLChecker unit test', () => { let graph = {} let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) + return expect(acl.checkAccess(acls, user, mode, resource)) .to.be.rejectedWith('ACL file found but no matching policy found') }) it('should callback with error on grant error', () => { @@ -49,7 +51,8 @@ describe('ACLChecker unit test', () => { let graph = {} let user, mode, resource, aclUrl let acl = new ACLChecker({ debug }) - return expect(acl.checkAccess(graph, user, mode, resource, true, aclUrl)) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) + return expect(acl.checkAccess(acls, user, mode, resource)) .to.be.rejectedWith('Error thrown during checkAccess()') }) }) From ab00c742b7919341727e49915659c34e62a5a5c2 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 10:58:25 -0400 Subject: [PATCH 146/178] Move resource parameter to constructor. --- lib/acl-checker.js | 33 ++++++++++++++++++--------------- lib/handlers/allow.js | 22 +++++++++++----------- test/unit/acl-checker-test.js | 18 +++++++++--------- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index c090ce813..c27af6a1c 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -9,33 +9,34 @@ const HTTPError = require('./http-error') const DEFAULT_ACL_SUFFIX = '.acl' class ACLChecker { - constructor (options = {}) { + constructor (resource, options = {}) { + this.resource = resource this.debug = options.debug || console.log.bind(console) this.fetch = options.fetch this.strictOrigin = options.strictOrigin this.suffix = options.suffix || DEFAULT_ACL_SUFFIX } - can (user, mode, resource, options = {}) { + can (user, mode, options = {}) { const debug = this.debug - debug('Can ' + (user || 'an agent') + ' ' + mode + ' ' + resource + '?') + this.debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) // If this is an ACL, Control mode must be present for any operations - if (this.isAcl(resource)) { + if (this.isAcl(this.resource)) { mode = 'Control' } // Check the permissions within the nearest ACL - return this.getNearestACL(resource) + return this.getNearestACL(this.resource) .then(nearestAcl => { - const acls = this.getPermissionSet(nearestAcl, resource, options) - return this.checkAccess(acls, user, mode, resource) + const acls = this.getPermissionSet(nearestAcl, options) + return this.checkAccess(acls, user, mode, this.resource) }) .then(() => { debug('ACL policy found') }) .catch(err => { debug(`Error: ${err.message}`) if (!user) { debug('Authentication required') - throw new HTTPError(401, `Access to ${resource} requires authorization`) + throw new HTTPError(401, `Access to ${this.resource} requires authorization`) } else { debug(`${mode} access denied for ${user}`) throw new HTTPError(403, `Access denied for ${user}`) @@ -44,10 +45,10 @@ class ACLChecker { } // Gets the ACL that applies to the resource - getNearestACL (uri) { + getNearestACL () { let isContainer = false let nearestACL = Promise.reject() - for (const acl of this.getPossibleACLs(uri, this.suffix)) { + for (const acl of this.getPossibleACLs()) { nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { this.debug(`Check if ACL exists: ${acl}`) this.fetch(acl, (err, graph) => { @@ -65,7 +66,9 @@ class ACLChecker { } // Get all possible ACL paths that apply to the resource - getPossibleACLs (uri, suffix) { + getPossibleACLs () { + var uri = this.resource + var suffix = this.suffix var first = uri.endsWith(suffix) ? uri : uri + suffix var urls = [first] var parsedUri = url.parse(uri) @@ -89,8 +92,8 @@ class ACLChecker { } // Tests whether the permissions allow a given operation - checkAccess (permissionSet, user, mode, resource) { - return permissionSet.checkAccess(resource, user, mode) + checkAccess (permissionSet, user, mode) { + return permissionSet.checkAccess(this.resource, user, mode) .then(hasAccess => { if (hasAccess) { this.debug(`${mode} access permitted to ${user}`) @@ -108,7 +111,7 @@ class ACLChecker { } // Gets the permission set for the given resource - getPermissionSet ({ acl, graph, isContainer }, resource, options = {}) { + getPermissionSet ({ acl, graph, isContainer }, options = {}) { const debug = this.debug if (!graph || graph.length === 0) { debug('ACL ' + acl + ' is empty') @@ -124,7 +127,7 @@ class ACLChecker { isAcl: uri => this.isAcl(uri), aclUrlFor: uri => this.aclUrlFor(uri) } - return new PermissionSet(resource, acl, isContainer, aclOptions) + return new PermissionSet(this.resource, acl, isContainer, aclOptions) } aclUrlFor (uri) { diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index d8be7727d..046c0e5ed 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -13,15 +13,6 @@ function allow (mode) { if (!ldp.webid) { return next() } - var baseUri = utils.uriBase(req) - - var acl = new ACL({ - debug: debug, - fetch: fetchDocument(req.hostname, ldp, baseUri), - suffix: ldp.suffixAcl, - strictOrigin: ldp.strictOrigin - }) - req.acl = acl var reqPath = res && res.locals && res.locals.path ? res.locals.path @@ -37,8 +28,17 @@ function allow (mode) { origin: req.get('origin'), host: req.protocol + '://' + req.get('host') } - acl.can(req.session.userId, mode, baseUri + reqPath, options) - .then(next, next) + + var baseUri = utils.uriBase(req) + var acl = new ACL(baseUri + reqPath, { + debug: debug, + fetch: fetchDocument(req.hostname, ldp, baseUri), + suffix: ldp.suffixAcl, + strictOrigin: ldp.strictOrigin + }) + req.acl = acl + + acl.can(req.session.userId, mode, options).then(next, next) }) } } diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.js index 2bc67015e..dc4cc4e4e 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.js @@ -28,9 +28,9 @@ describe('ACLChecker unit test', () => { }) let graph = {} let user, mode, resource, aclUrl - let acl = new ACLChecker({ debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) - return expect(acl.checkAccess(acls, user, mode, resource)) + let acl = new ACLChecker(resource, { debug }) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }) + return expect(acl.checkAccess(acls, user, mode)) .to.eventually.be.true }) it('should callback with error on grant failure', () => { @@ -39,9 +39,9 @@ describe('ACLChecker unit test', () => { }) let graph = {} let user, mode, resource, aclUrl - let acl = new ACLChecker({ debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) - return expect(acl.checkAccess(acls, user, mode, resource)) + let acl = new ACLChecker(resource, { debug }) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }) + return expect(acl.checkAccess(acls, user, mode)) .to.be.rejectedWith('ACL file found but no matching policy found') }) it('should callback with error on grant error', () => { @@ -50,9 +50,9 @@ describe('ACLChecker unit test', () => { }) let graph = {} let user, mode, resource, aclUrl - let acl = new ACLChecker({ debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }, resource) - return expect(acl.checkAccess(acls, user, mode, resource)) + let acl = new ACLChecker(resource, { debug }) + let acls = acl.getPermissionSet({ graph, acl: aclUrl }) + return expect(acl.checkAccess(acls, user, mode)) .to.be.rejectedWith('Error thrown during checkAccess()') }) }) From 31d396ac7042de9e707c816b5b30f90666a31c89 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 11:08:04 -0400 Subject: [PATCH 147/178] Move all options to constructor. --- lib/acl-checker.js | 12 +++++++----- lib/handlers/allow.js | 8 +++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index c27af6a1c..8eb2bcbea 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -11,13 +11,15 @@ const DEFAULT_ACL_SUFFIX = '.acl' class ACLChecker { constructor (resource, options = {}) { this.resource = resource + this.host = options.host + this.origin = options.origin this.debug = options.debug || console.log.bind(console) this.fetch = options.fetch this.strictOrigin = options.strictOrigin this.suffix = options.suffix || DEFAULT_ACL_SUFFIX } - can (user, mode, options = {}) { + can (user, mode) { const debug = this.debug this.debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) // If this is an ACL, Control mode must be present for any operations @@ -28,7 +30,7 @@ class ACLChecker { // Check the permissions within the nearest ACL return this.getNearestACL(this.resource) .then(nearestAcl => { - const acls = this.getPermissionSet(nearestAcl, options) + const acls = this.getPermissionSet(nearestAcl) return this.checkAccess(acls, user, mode, this.resource) }) .then(() => { debug('ACL policy found') }) @@ -111,7 +113,7 @@ class ACLChecker { } // Gets the permission set for the given resource - getPermissionSet ({ acl, graph, isContainer }, options = {}) { + getPermissionSet ({ acl, graph, isContainer }) { const debug = this.debug if (!graph || graph.length === 0) { debug('ACL ' + acl + ' is empty') @@ -120,8 +122,8 @@ class ACLChecker { const aclOptions = { aclSuffix: this.suffix, graph: graph, - host: options.host, - origin: options.origin, + host: this.host, + origin: this.origin, rdf: rdf, strictOrigin: this.strictOrigin, isAcl: uri => this.isAcl(uri), diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 046c0e5ed..c1bfa7468 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -24,13 +24,11 @@ function allow (mode) { if (!reqPath.endsWith('/') && !err && stat.isDirectory()) { reqPath += '/' } - var options = { - origin: req.get('origin'), - host: req.protocol + '://' + req.get('host') - } var baseUri = utils.uriBase(req) var acl = new ACL(baseUri + reqPath, { + origin: req.get('origin'), + host: req.protocol + '://' + req.get('host'), debug: debug, fetch: fetchDocument(req.hostname, ldp, baseUri), suffix: ldp.suffixAcl, @@ -38,7 +36,7 @@ function allow (mode) { }) req.acl = acl - acl.can(req.session.userId, mode, options).then(next, next) + acl.can(req.session.userId, mode).then(next, next) }) } } From 180de68a2246758082a507b82d9d37ff3d60c922 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 11:18:41 -0400 Subject: [PATCH 148/178] Cache the permission set. --- lib/acl-checker.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 8eb2bcbea..90b1164b7 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -19,29 +19,32 @@ class ACLChecker { this.suffix = options.suffix || DEFAULT_ACL_SUFFIX } + // Returns a fulfilled promise when the user can access the resource + // in the given mode, or a rejected promise otherwise can (user, mode) { - const debug = this.debug this.debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) // If this is an ACL, Control mode must be present for any operations if (this.isAcl(this.resource)) { mode = 'Control' } - // Check the permissions within the nearest ACL - return this.getNearestACL(this.resource) - .then(nearestAcl => { - const acls = this.getPermissionSet(nearestAcl) - return this.checkAccess(acls, user, mode, this.resource) - }) - .then(() => { debug('ACL policy found') }) + // Obtain the permission set for the resource + if (!this._permissionSet) { + this._permissionSet = this.getNearestACL() + .then(acl => this.getPermissionSet(acl)) + } + + // Check the permissions + return this._permissionSet.then(acls => this.checkAccess(acls, user, mode)) + .then(() => { this.debug('ACL policy found') }) .catch(err => { - debug(`Error: ${err.message}`) + this.debug(`Error: ${err.message}`) if (!user) { - debug('Authentication required') + this.debug('Authentication required') throw new HTTPError(401, `Access to ${this.resource} requires authorization`) } else { - debug(`${mode} access denied for ${user}`) - throw new HTTPError(403, `Access denied for ${user}`) + this.debug(`${mode} access denied for ${user}`) + throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`) } }) } @@ -112,7 +115,7 @@ class ACLChecker { }) } - // Gets the permission set for the given resource + // Gets the permission set for the given ACL getPermissionSet ({ acl, graph, isContainer }) { const debug = this.debug if (!graph || graph.length === 0) { From 0a58ac5c937fdcbb1bf3978293bee8c5d66c8e0e Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 13:13:19 -0400 Subject: [PATCH 149/178] Clean up ACLChecker. --- lib/acl-checker.js | 60 +++++++++------------ lib/handlers/allow.js | 22 ++++---- package-lock.json | 41 --------------- package.json | 1 - test/unit/acl-checker-test.js | 99 +++++++++++++++++++---------------- 5 files changed, 91 insertions(+), 132 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 90b1164b7..2dab03aa6 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -4,25 +4,26 @@ const path = require('path') const PermissionSet = require('solid-permissions').PermissionSet const rdf = require('rdflib') const url = require('url') +const debug = require('./debug').ACL const HTTPError = require('./http-error') const DEFAULT_ACL_SUFFIX = '.acl' +// An ACLChecker exposes the permissions on a specific resource class ACLChecker { constructor (resource, options = {}) { this.resource = resource this.host = options.host this.origin = options.origin - this.debug = options.debug || console.log.bind(console) this.fetch = options.fetch this.strictOrigin = options.strictOrigin this.suffix = options.suffix || DEFAULT_ACL_SUFFIX } // Returns a fulfilled promise when the user can access the resource - // in the given mode, or a rejected promise otherwise + // in the given mode, or rejects with an HTTP error otherwise can (user, mode) { - this.debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) + debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) // If this is an ACL, Control mode must be present for any operations if (this.isAcl(this.resource)) { mode = 'Control' @@ -34,16 +35,15 @@ class ACLChecker { .then(acl => this.getPermissionSet(acl)) } - // Check the permissions + // Check the resource's permissions return this._permissionSet.then(acls => this.checkAccess(acls, user, mode)) - .then(() => { this.debug('ACL policy found') }) .catch(err => { - this.debug(`Error: ${err.message}`) + debug(`Error: ${err.message}`) if (!user) { - this.debug('Authentication required') + debug('Authentication required') throw new HTTPError(401, `Access to ${this.resource} requires authorization`) } else { - this.debug(`${mode} access denied for ${user}`) + debug(`${mode} access denied for ${user}`) throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`) } }) @@ -52,13 +52,14 @@ class ACLChecker { // Gets the ACL that applies to the resource getNearestACL () { let isContainer = false + // Create a cascade of reject handlers (one for each possible ACL) let nearestACL = Promise.reject() for (const acl of this.getPossibleACLs()) { nearestACL = nearestACL.catch(() => new Promise((resolve, reject) => { - this.debug(`Check if ACL exists: ${acl}`) + debug(`Check if ACL exists: ${acl}`) this.fetch(acl, (err, graph) => { if (err || !graph || !graph.length) { - if (err) this.debug(`Error reading ${acl}: ${err}`) + if (err) debug(`Error reading ${acl}: ${err}`) isContainer = true reject(err) } else { @@ -70,26 +71,26 @@ class ACLChecker { return nearestACL.catch(e => { throw new Error('No ACL resource found') }) } - // Get all possible ACL paths that apply to the resource + // Gets all possible ACL paths that apply to the resource getPossibleACLs () { - var uri = this.resource - var suffix = this.suffix - var first = uri.endsWith(suffix) ? uri : uri + suffix - var urls = [first] - var parsedUri = url.parse(uri) - var baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') + + let uri = this.resource + const suffix = this.suffix + const first = uri.endsWith(suffix) ? uri : uri + suffix + const urls = [first] + const parsedUri = url.parse(uri) + const baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') + (parsedUri.host || '') if (baseUrl + '/' === uri) { return urls } - var times = parsedUri.pathname.split('/').length + let times = parsedUri.pathname.split('/').length // TODO: improve temporary solution to stop recursive path walking above root if (parsedUri.pathname.endsWith('/')) { times-- } - for (var i = 0; i < times - 1; i++) { + for (let i = 0; i < times - 1; i++) { uri = path.dirname(uri) urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix)) } @@ -101,23 +102,22 @@ class ACLChecker { return permissionSet.checkAccess(this.resource, user, mode) .then(hasAccess => { if (hasAccess) { - this.debug(`${mode} access permitted to ${user}`) + debug(`${mode} access permitted to ${user}`) return true } else { - this.debug(`${mode} access NOT permitted to ${user}`) + debug(`${mode} access NOT permitted to ${user}`) throw new Error('ACL file found but no matching policy found') } }) .catch(err => { - this.debug(`${mode} access denied to ${user}`) - this.debug(err) + debug(`${mode} access denied to ${user}`) + debug(err) throw err }) } // Gets the permission set for the given ACL getPermissionSet ({ acl, graph, isContainer }) { - const debug = this.debug if (!graph || graph.length === 0) { debug('ACL ' + acl + ' is empty') throw new Error('No policy found - empty ACL') @@ -136,19 +136,11 @@ class ACLChecker { } aclUrlFor (uri) { - if (this.isAcl(uri)) { - return uri - } else { - return uri + this.suffix - } + return this.isAcl(uri) ? uri : uri + this.suffix } isAcl (resource) { - if (typeof resource === 'string') { - return resource.endsWith(this.suffix) - } else { - return false - } + return resource.endsWith(this.suffix) } } diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index c1bfa7468..9dec5f4d5 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -4,7 +4,6 @@ var ACL = require('../acl-checker') var $rdf = require('rdflib') var url = require('url') var async = require('async') -var debug = require('../debug').ACL var utils = require('../utils') function allow (mode) { @@ -14,29 +13,32 @@ function allow (mode) { return next() } + // Determine the actual path of the request var reqPath = res && res.locals && res.locals.path ? res.locals.path : req.path + + // Check whether the resource exists ldp.exists(req.hostname, reqPath, (err, ret) => { - if (ret) { - var stat = ret.stream - } - if (!reqPath.endsWith('/') && !err && stat.isDirectory()) { + // Ensure directories always end in a slash + const stat = err ? null : ret.stream + if (!reqPath.endsWith('/') && stat && stat.isDirectory()) { reqPath += '/' } - var baseUri = utils.uriBase(req) - var acl = new ACL(baseUri + reqPath, { + // Obtain and store the ACL of the requested resource + const baseUri = utils.uriBase(req) + req.acl = new ACL(baseUri + reqPath, { origin: req.get('origin'), host: req.protocol + '://' + req.get('host'), - debug: debug, fetch: fetchDocument(req.hostname, ldp, baseUri), suffix: ldp.suffixAcl, strictOrigin: ldp.strictOrigin }) - req.acl = acl - acl.can(req.session.userId, mode).then(next, next) + // Ensure the user has the required permission + req.acl.can(req.session.userId, mode) + .then(() => next(), next) }) } } diff --git a/package-lock.json b/package-lock.json index 6df92ea1d..4e6f44dff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1864,16 +1864,6 @@ "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=" }, - "fill-keys": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", - "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", - "dev": true, - "requires": { - "is-object": "1.0.1", - "merge-descriptors": "1.0.1" - } - }, "fill-range": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", @@ -2678,12 +2668,6 @@ "kind-of": "3.2.2" } }, - "is-object": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", - "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=", - "dev": true - }, "is-path-cwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", @@ -3282,12 +3266,6 @@ } } }, - "module-not-found-error": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", - "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", - "dev": true - }, "moment": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", @@ -5258,25 +5236,6 @@ "ipaddr.js": "1.4.0" } }, - "proxyquire": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-1.8.0.tgz", - "integrity": "sha1-AtUUpb7ZhvBMuyCTrxZ0FTX3ntw=", - "dev": true, - "requires": { - "fill-keys": "1.0.2", - "module-not-found-error": "1.0.1", - "resolve": "1.1.7" - }, - "dependencies": { - "resolve": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", - "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", - "dev": true - } - } - }, "public-encrypt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.0.tgz", diff --git a/package.json b/package.json index 1ea847b3e..f30f3ffc9 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "nock": "^9.0.14", "node-mocks-http": "^1.5.6", "nyc": "^10.1.2", - "proxyquire": "^1.7.10", "sinon": "^2.1.0", "sinon-chai": "^2.8.0", "solid-auth-oidc": "^0.2.0", diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.js index dc4cc4e4e..316227f2d 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.js @@ -1,58 +1,65 @@ 'use strict' -const proxyquire = require('proxyquire') -const debug = require('../../lib/debug').ACL +const ACLChecker = require('../../lib/acl-checker') const chai = require('chai') const { expect } = chai chai.use(require('chai-as-promised')) -class PermissionSetAlwaysGrant { - checkAccess () { - return Promise.resolve(true) - } -} -class PermissionSetNeverGrant { - checkAccess () { - return Promise.resolve(false) - } -} -class PermissionSetAlwaysError { - checkAccess () { - return Promise.reject(new Error('Error thrown during checkAccess()')) - } -} - describe('ACLChecker unit test', () => { - it('should callback with null on grant success', () => { - let ACLChecker = proxyquire('../../lib/acl-checker', { - 'solid-permissions': { PermissionSet: PermissionSetAlwaysGrant } + describe('checkAccess', () => { + it('should callback with null on grant success', () => { + let acl = new ACLChecker() + let acls = { checkAccess: () => Promise.resolve(true) } + return expect(acl.checkAccess(acls)).to.eventually.be.true }) - let graph = {} - let user, mode, resource, aclUrl - let acl = new ACLChecker(resource, { debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }) - return expect(acl.checkAccess(acls, user, mode)) - .to.eventually.be.true - }) - it('should callback with error on grant failure', () => { - let ACLChecker = proxyquire('../../lib/acl-checker', { - 'solid-permissions': { PermissionSet: PermissionSetNeverGrant } + it('should callback with error on grant failure', () => { + let acl = new ACLChecker() + let acls = { checkAccess: () => Promise.resolve(false) } + return expect(acl.checkAccess(acls)) + .to.be.rejectedWith('ACL file found but no matching policy found') + }) + it('should callback with error on grant error', () => { + let acl = new ACLChecker() + let acls = { checkAccess: () => Promise.reject(new Error('my error')) } + return expect(acl.checkAccess(acls)).to.be.rejectedWith('my error') }) - let graph = {} - let user, mode, resource, aclUrl - let acl = new ACLChecker(resource, { debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }) - return expect(acl.checkAccess(acls, user, mode)) - .to.be.rejectedWith('ACL file found but no matching policy found') }) - it('should callback with error on grant error', () => { - let ACLChecker = proxyquire('../../lib/acl-checker', { - 'solid-permissions': { PermissionSet: PermissionSetAlwaysError } + + describe('getPossibleACLs', () => { + it('returns all possible ACLs of the root', () => { + const aclChecker = new ACLChecker('http://ex.org/') + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a regular file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi') + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of an ACL file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi.acl') + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a directory', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi/') + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi/.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) }) - let graph = {} - let user, mode, resource, aclUrl - let acl = new ACLChecker(resource, { debug }) - let acls = acl.getPermissionSet({ graph, acl: aclUrl }) - return expect(acl.checkAccess(acls, user, mode)) - .to.be.rejectedWith('Error thrown during checkAccess()') }) }) From d7a429f78217d941afa39d6bbff435bc0318e881 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 13:46:17 -0400 Subject: [PATCH 150/178] Simplify ACL path algorithm. --- lib/acl-checker.js | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 2dab03aa6..da221d04d 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -1,9 +1,7 @@ 'use strict' -const path = require('path') const PermissionSet = require('solid-permissions').PermissionSet const rdf = require('rdflib') -const url = require('url') const debug = require('./debug').ACL const HTTPError = require('./http-error') @@ -73,28 +71,21 @@ class ACLChecker { // Gets all possible ACL paths that apply to the resource getPossibleACLs () { - let uri = this.resource - const suffix = this.suffix - const first = uri.endsWith(suffix) ? uri : uri + suffix - const urls = [first] - const parsedUri = url.parse(uri) - const baseUrl = (parsedUri.protocol ? parsedUri.protocol + '//' : '') + - (parsedUri.host || '') - if (baseUrl + '/' === uri) { - return urls - } + // Obtain the resource URI and the length of its base + let { resource: uri, suffix } = this + const [ { length: base } ] = uri.match(/^[^:]+:\/*[^/]+/) - let times = parsedUri.pathname.split('/').length - // TODO: improve temporary solution to stop recursive path walking above root - if (parsedUri.pathname.endsWith('/')) { - times-- + // If the URI points to a file, append the file's ACL + const possibleAcls = [] + if (!uri.endsWith('/')) { + possibleAcls.push(uri.endsWith(suffix) ? uri : uri + suffix) } - for (let i = 0; i < times - 1; i++) { - uri = path.dirname(uri) - urls.push(uri + (uri[uri.length - 1] === '/' ? suffix : '/' + suffix)) + // Append the ACLs of all parent directories + for (let i = lastSlash(uri); i >= base; i = lastSlash(uri, i - 1)) { + possibleAcls.push(uri.substr(0, i + 1) + suffix) } - return urls + return possibleAcls } // Tests whether the permissions allow a given operation @@ -144,5 +135,10 @@ class ACLChecker { } } +// Returns the index of the last slash before the given position +function lastSlash (string, pos = string.length) { + return string.lastIndexOf('/', pos) +} + module.exports = ACLChecker module.exports.DEFAULT_ACL_SUFFIX = DEFAULT_ACL_SUFFIX From 3161b684398df27f2bf59ab2c53f4316fe61d3f1 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 14:47:20 -0400 Subject: [PATCH 151/178] Indent then and catch. --- lib/acl-checker.js | 51 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index da221d04d..4aef8dfa8 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -34,17 +34,18 @@ class ACLChecker { } // Check the resource's permissions - return this._permissionSet.then(acls => this.checkAccess(acls, user, mode)) - .catch(err => { - debug(`Error: ${err.message}`) - if (!user) { - debug('Authentication required') - throw new HTTPError(401, `Access to ${this.resource} requires authorization`) - } else { - debug(`${mode} access denied for ${user}`) - throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`) - } - }) + return this._permissionSet + .then(acls => this.checkAccess(acls, user, mode)) + .catch(err => { + debug(`Error: ${err.message}`) + if (!user) { + debug('Authentication required') + throw new HTTPError(401, `Access to ${this.resource} requires authorization`) + } else { + debug(`${mode} access denied for ${user}`) + throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`) + } + }) } // Gets the ACL that applies to the resource @@ -91,20 +92,20 @@ class ACLChecker { // Tests whether the permissions allow a given operation checkAccess (permissionSet, user, mode) { return permissionSet.checkAccess(this.resource, user, mode) - .then(hasAccess => { - if (hasAccess) { - debug(`${mode} access permitted to ${user}`) - return true - } else { - debug(`${mode} access NOT permitted to ${user}`) - throw new Error('ACL file found but no matching policy found') - } - }) - .catch(err => { - debug(`${mode} access denied to ${user}`) - debug(err) - throw err - }) + .then(hasAccess => { + if (hasAccess) { + debug(`${mode} access permitted to ${user}`) + return true + } else { + debug(`${mode} access NOT permitted to ${user}`) + throw new Error('ACL file found but no matching policy found') + } + }) + .catch(err => { + debug(`${mode} access denied to ${user}`) + debug(err) + throw err + }) } // Gets the permission set for the given ACL From 2b8f18bb33e0fdd0b7e96f9cca1500fb24eda114 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 17 Aug 2017 17:04:16 -0400 Subject: [PATCH 152/178] Expose the user's permissions through a header. Closes #246. --- lib/header.js | 27 ++++++++++ lib/ldp-middleware.js | 2 +- test/integration/header-test.js | 53 +++++++++++++++++++ test/resources/headers/index.html | 0 test/resources/headers/public-ra | 0 test/resources/headers/public-ra.acl | 7 +++ test/resources/headers/user-rw-public-r | 0 test/resources/headers/user-rw-public-r.acl | 12 +++++ test/resources/headers/user-rwac-public-0 | 0 test/resources/headers/user-rwac-public-0.acl | 7 +++ 10 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 test/integration/header-test.js create mode 100644 test/resources/headers/index.html create mode 100644 test/resources/headers/public-ra create mode 100644 test/resources/headers/public-ra.acl create mode 100644 test/resources/headers/user-rw-public-r create mode 100644 test/resources/headers/user-rw-public-r.acl create mode 100644 test/resources/headers/user-rwac-public-0 create mode 100644 test/resources/headers/user-rwac-public-0.acl diff --git a/lib/header.js b/lib/header.js index 4523acb52..496029258 100644 --- a/lib/header.js +++ b/lib/header.js @@ -2,6 +2,7 @@ module.exports.addLink = addLink module.exports.addLinks = addLinks module.exports.parseMetadataFromHeader = parseMetadataFromHeader module.exports.linksHandler = linksHandler +module.exports.addPermissions = addPermissions var li = require('li') var path = require('path') @@ -11,6 +12,9 @@ var debug = require('./debug.js') var utils = require('./utils.js') var error = require('./http-error') +const MODES = ['Read', 'Write', 'Append', 'Control'] +const PERMISSIONS = MODES.map(m => m.toLowerCase()) + function addLink (res, value, rel) { var oldLink = res.get('Link') if (oldLink === undefined) { @@ -95,3 +99,26 @@ function parseMetadataFromHeader (linkHeader) { } return fileMetadata } + +// Adds a header that describes the user's permissions +function addPermissions (req, res, next) { + const { acl, session, path } = req + if (!acl) return next() + + // Turn permissions for the public and the user into a header + const resource = utils.uriAbs(req) + path + Promise.all([ + getPermissionsFor(acl, null, resource), + getPermissionsFor(acl, session.userId, resource) + ]) + .then(([publicPerms, userPerms]) => { + res.set('WAC-Allow', `user="${userPerms}",public="${publicPerms}"`) + }) + .then(next, next) +} + +// Gets the permissions string for the given user and resource +function getPermissionsFor (acl, user, resource) { + return Promise.all(MODES.map(mode => acl.can(user, mode).catch(e => false))) + .then(allowed => PERMISSIONS.filter((_, i) => allowed[i]).join(' ')) +} diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js index 4e8ae0dbe..a6bcb1fbd 100644 --- a/lib/ldp-middleware.js +++ b/lib/ldp-middleware.js @@ -22,7 +22,7 @@ function LdpMiddleware (corsSettings) { } router.copy('/*', allow('Write'), copy) - router.get('/*', index, allow('Read'), get) + router.get('/*', index, allow('Read'), header.addPermissions, get) router.post('/*', allow('Append'), post) router.patch('/*', allow('Write'), patch) router.put('/*', allow('Write'), put) diff --git a/test/integration/header-test.js b/test/integration/header-test.js new file mode 100644 index 000000000..452857576 --- /dev/null +++ b/test/integration/header-test.js @@ -0,0 +1,53 @@ +const { expect } = require('chai') +const path = require('path') +const ldnode = require('../../index') +const supertest = require('supertest') + +const serverOptions = { + root: path.join(__dirname, '../resources/headers'), + idp: false, + webid: true, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + forceUser: 'https://ruben.verborgh.org/profile/#me' +} + +describe('Header handler', () => { + let request + + before(() => { + const server = ldnode.createServer(serverOptions) + request = supertest(server) + }) + + describe('WAC-Allow', () => { + describeHeaderTest('read/append for the public', { + resource: '/public-ra', + headers: { 'WAC-Allow': 'user="read append",public="read append"' } + }) + + describeHeaderTest('read/write for the user, read for the public', { + resource: '/user-rw-public-r', + headers: { 'WAC-Allow': 'user="read write append",public="read"' } + }) + + describeHeaderTest('read/write/append/control for the user, nothing for the public', { + resource: '/user-rwac-public-0', + headers: { 'WAC-Allow': 'user="read write append control",public=""' } + }) + }) + + function describeHeaderTest (label, { resource, headers }) { + describe(`a resource that is ${label}`, () => { + let response + before(() => request.get(resource).then(res => { response = res })) + + for (const header in headers) { + const value = headers[header] + it(`has a ${header} header of ${value}`, () => { + expect(response.headers).to.have.property(header.toLowerCase(), value) + }) + } + }) + } +}) diff --git a/test/resources/headers/index.html b/test/resources/headers/index.html new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/headers/public-ra b/test/resources/headers/public-ra new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/headers/public-ra.acl b/test/resources/headers/public-ra.acl new file mode 100644 index 000000000..193a27b44 --- /dev/null +++ b/test/resources/headers/public-ra.acl @@ -0,0 +1,7 @@ +@prefix acl: <http://www.w3.org/ns/auth/acl#>. +@prefix foaf: <http://xmlns.com/foaf/0.1/>. + +<#public> a acl:Authorization; + acl:accessTo <./public-ra>; + acl:agentClass foaf:Agent; + acl:mode acl:Read, acl:Append. diff --git a/test/resources/headers/user-rw-public-r b/test/resources/headers/user-rw-public-r new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/headers/user-rw-public-r.acl b/test/resources/headers/user-rw-public-r.acl new file mode 100644 index 000000000..3f5cf032d --- /dev/null +++ b/test/resources/headers/user-rw-public-r.acl @@ -0,0 +1,12 @@ +@prefix acl: <http://www.w3.org/ns/auth/acl#>. +@prefix foaf: <http://xmlns.com/foaf/0.1/>. + +<#owner> a acl:Authorization; + acl:accessTo <./user-rw-public-r>; + acl:agent <https://ruben.verborgh.org/profile/#me>; + acl:mode acl:Read, acl:Write. + +<#public> a acl:Authorization; + acl:accessTo <./user-rw-public-r>; + acl:agentClass foaf:Agent; + acl:mode acl:Read. diff --git a/test/resources/headers/user-rwac-public-0 b/test/resources/headers/user-rwac-public-0 new file mode 100644 index 000000000..e69de29bb diff --git a/test/resources/headers/user-rwac-public-0.acl b/test/resources/headers/user-rwac-public-0.acl new file mode 100644 index 000000000..0061e5897 --- /dev/null +++ b/test/resources/headers/user-rwac-public-0.acl @@ -0,0 +1,7 @@ +@prefix acl: <http://www.w3.org/ns/auth/acl#>. +@prefix foaf: <http://xmlns.com/foaf/0.1/>. + +<#owner> a acl:Authorization; + acl:accessTo <./user-rwac-public-0>; + acl:agent <https://ruben.verborgh.org/profile/#me>; + acl:mode acl:Read, acl:Write, acl:Append, acl:Delete, acl:Control. From 91c810687ee398f6244a7468f5e56fc1781b822c Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 17 Aug 2017 14:27:59 -0400 Subject: [PATCH 153/178] Migrate to Solid vocabulary. Depends on https://github.com/solid/vocab/pull/25. --- lib/handlers/patch/n3-patch-parser.js | 12 +- test/integration/patch.js | 174 +++++++++++++------------- 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/lib/handlers/patch/n3-patch-parser.js b/lib/handlers/patch/n3-patch-parser.js index fe2cf3e0c..91df7c5b6 100644 --- a/lib/handlers/patch/n3-patch-parser.js +++ b/lib/handlers/patch/n3-patch-parser.js @@ -5,8 +5,8 @@ module.exports = parsePatchDocument const $rdf = require('rdflib') const error = require('../../http-error') -const PATCH_NS = 'http://example.org/patch#' -const PREFIXES = `PREFIX p: <${PATCH_NS}>\n` +const PATCH_NS = 'http://www.w3.org/ns/solid/terms#' +const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n` // Parses the given N3 patch document function parsePatchDocument (targetURI, patchURI, patchText) { @@ -21,10 +21,10 @@ function parsePatchDocument (targetURI, patchURI, patchText) { // Query the N3 document for insertions and deletions .then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES} SELECT ?insert ?delete ?where WHERE { - ?patch p:patches <${targetURI}>. - OPTIONAL { ?patch p:insert ?insert. } - OPTIONAL { ?patch p:delete ?delete. } - OPTIONAL { ?patch p:where ?where. } + ?patch solid:patches <${targetURI}>. + OPTIONAL { ?patch solid:inserts ?insert. } + OPTIONAL { ?patch solid:deletes ?delete. } + OPTIONAL { ?patch solid:where ?where. } }`) .catch(err => { throw error(400, `No patch for ${targetURI} found.`, err) }) ) diff --git a/test/integration/patch.js b/test/integration/patch.js index c715e3a7e..b4b528f1a 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -51,7 +51,7 @@ describe('PATCH', () => { describe('without relevant patch element', describePatch({ path: '/read-write.ttl', - patch: `<> a p:Patch.` + patch: `<> a solid:Patch.` }, { // expected: status: 400, text: 'No patch for https://tim.localhost:7777/read-write.ttl found' @@ -59,7 +59,7 @@ describe('PATCH', () => { describe('with neither insert nor delete', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>.` }, { // expected: status: 400, text: 'Patch should at least contain inserts or deletes' @@ -70,8 +70,8 @@ describe('PATCH', () => { describe('on a non-existing file', describePatch({ path: '/new.ttl', exists: false, - patch: `<> p:patches <https://tim.localhost:7777/new.ttl>; - p:insert { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/new.ttl>; + solid:inserts { <x> <y> <z>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -80,8 +80,8 @@ describe('PATCH', () => { describe('on a resource with read-only access', describePatch({ path: '/read-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-only.ttl>; - p:insert { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-only.ttl>; + solid:inserts { <x> <y> <z>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -89,8 +89,8 @@ describe('PATCH', () => { describe('on a resource with append-only access', describePatch({ path: '/append-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/append-only.ttl>; - p:insert { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/append-only.ttl>; + solid:inserts { <x> <y> <z>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -99,8 +99,8 @@ describe('PATCH', () => { describe('on a resource with write-only access', describePatch({ path: '/write-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/write-only.ttl>; - p:insert { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/write-only.ttl>; + solid:inserts { <x> <y> <z>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -112,9 +112,9 @@ describe('PATCH', () => { describe('on a non-existing file', describePatch({ path: '/new.ttl', exists: false, - patch: `<> p:patches <https://tim.localhost:7777/new.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/new.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -122,9 +122,9 @@ describe('PATCH', () => { describe('on a resource with read-only access', describePatch({ path: '/read-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-only.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-only.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -132,9 +132,9 @@ describe('PATCH', () => { describe('on a resource with append-only access', describePatch({ path: '/append-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/append-only.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/append-only.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -142,9 +142,9 @@ describe('PATCH', () => { describe('on a resource with write-only access', describePatch({ path: '/write-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/write-only.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/write-only.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: // Allowing the insert would either return 200 or 409, // thereby inappropriately giving the user (guess-based) read access; @@ -156,9 +156,9 @@ describe('PATCH', () => { describe('on a resource with read-append access', () => { describe('with a matching WHERE clause', describePatch({ path: '/read-append.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-append.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-append.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -167,9 +167,9 @@ describe('PATCH', () => { describe('with a non-matching WHERE clause', describePatch({ path: '/read-append.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-append.ttl>; - p:where { ?a <y> <z>. }; - p:insert { ?a <s> <t>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-append.ttl>; + solid:where { ?a <y> <z>. }; + solid:inserts { ?a <s> <t>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -179,9 +179,9 @@ describe('PATCH', () => { describe('on a resource with read-write access', () => { describe('with a matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:insert { ?a <y> <z>. }; - p:where { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:inserts { ?a <y> <z>. }; + solid:where { ?a <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -190,9 +190,9 @@ describe('PATCH', () => { describe('with a non-matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:where { ?a <y> <z>. }; - p:insert { ?a <s> <t>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:where { ?a <y> <z>. }; + solid:inserts { ?a <s> <t>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -204,8 +204,8 @@ describe('PATCH', () => { describe('on a non-existing file', describePatch({ path: '/new.ttl', exists: false, - patch: `<> p:patches <https://tim.localhost:7777/new.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/new.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -213,8 +213,8 @@ describe('PATCH', () => { describe('on a resource with read-only access', describePatch({ path: '/read-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-only.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-only.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -222,8 +222,8 @@ describe('PATCH', () => { describe('on a resource with append-only access', describePatch({ path: '/append-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/append-only.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/append-only.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -231,8 +231,8 @@ describe('PATCH', () => { describe('on a resource with write-only access', describePatch({ path: '/write-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/write-only.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/write-only.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: // Allowing the delete would either return 200 or 409, // thereby inappropriately giving the user (guess-based) read access; @@ -243,8 +243,8 @@ describe('PATCH', () => { describe('on a resource with read-append access', describePatch({ path: '/read-append.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-append.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-append.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -253,8 +253,8 @@ describe('PATCH', () => { describe('on a resource with read-write access', () => { describe('with a patch for existing data', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -263,8 +263,8 @@ describe('PATCH', () => { describe('with a patch for non-existing data', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:delete { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:deletes { <x> <y> <z>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -272,9 +272,9 @@ describe('PATCH', () => { describe('with a matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:where { ?a <b> <c>. }; - p:delete { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:where { ?a <b> <c>. }; + solid:deletes { ?a <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -283,9 +283,9 @@ describe('PATCH', () => { describe('with a non-matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:where { ?a <y> <z>. }; - p:delete { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:where { ?a <y> <z>. }; + solid:deletes { ?a <b> <c>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -297,9 +297,9 @@ describe('PATCH', () => { describe('on a non-existing file', describePatch({ path: '/new.ttl', exists: false, - patch: `<> p:patches <https://tim.localhost:7777/new.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/new.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -307,9 +307,9 @@ describe('PATCH', () => { describe('on a resource with read-only access', describePatch({ path: '/read-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-only.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-only.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -317,9 +317,9 @@ describe('PATCH', () => { describe('on a resource with append-only access', describePatch({ path: '/append-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/append-only.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/append-only.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -327,9 +327,9 @@ describe('PATCH', () => { describe('on a resource with write-only access', describePatch({ path: '/write-only.ttl', - patch: `<> p:patches <https://tim.localhost:7777/write-only.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/write-only.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: // Allowing the delete would either return 200 or 409, // thereby inappropriately giving the user (guess-based) read access; @@ -340,9 +340,9 @@ describe('PATCH', () => { describe('on a resource with read-append access', describePatch({ path: '/read-append.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-append.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-append.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 403, text: 'Access denied' @@ -351,9 +351,9 @@ describe('PATCH', () => { describe('on a resource with read-write access', () => { describe('executes deletes before inserts', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <x> <y> <z>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <x> <y> <z>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -361,9 +361,9 @@ describe('PATCH', () => { describe('with a patch for existing data', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <a> <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <a> <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -372,9 +372,9 @@ describe('PATCH', () => { describe('with a patch for non-existing data', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:insert { <x> <y> <z>. }; - p:delete { <q> <s> <s>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:inserts { <x> <y> <z>. }; + solid:deletes { <q> <s> <s>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -382,10 +382,10 @@ describe('PATCH', () => { describe('with a matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:where { ?a <b> <c>. }; - p:insert { ?a <y> <z>. }; - p:delete { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:where { ?a <b> <c>. }; + solid:inserts { ?a <y> <z>. }; + solid:deletes { ?a <b> <c>. }.` }, { // expected: status: 200, text: 'Patch applied successfully', @@ -394,10 +394,10 @@ describe('PATCH', () => { describe('with a non-matching WHERE clause', describePatch({ path: '/read-write.ttl', - patch: `<> p:patches <https://tim.localhost:7777/read-write.ttl>; - p:where { ?a <y> <z>. }; - p:insert { ?a <y> <z>. }; - p:delete { ?a <b> <c>. }.` + patch: `<> solid:patches <https://tim.localhost:7777/read-write.ttl>; + solid:where { ?a <y> <z>. }; + solid:inserts { ?a <y> <z>. }; + solid:deletes { ?a <b> <c>. }.` }, { // expected: status: 409, text: 'The patch could not be applied' @@ -431,7 +431,7 @@ describe('PATCH', () => { request.patch(path) .set('Authorization', `Bearer ${userCredentials}`) .set('Content-Type', contentType) - .send(`@prefix p: <http://example.org/patch#>.\n${patch}`) + .send(`@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n${patch}`) .then(res => { response = res }) .then(done, done) }) From 6c1938bef43fcd1a0c86834712c981088a59923e Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Fri, 18 Aug 2017 17:17:36 -0400 Subject: [PATCH 154/178] Fix rename missed in merge --- lib/header.js | 4 ++-- package-lock.json | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/header.js b/lib/header.js index 496029258..d7705bfbb 100644 --- a/lib/header.js +++ b/lib/header.js @@ -102,11 +102,11 @@ function parseMetadataFromHeader (linkHeader) { // Adds a header that describes the user's permissions function addPermissions (req, res, next) { - const { acl, session, path } = req + const { acl, session } = req if (!acl) return next() // Turn permissions for the public and the user into a header - const resource = utils.uriAbs(req) + path + const resource = utils.getFullUri(req) Promise.all([ getPermissionsFor(acl, null, resource), getPermissionsFor(acl, session.userId, resource) diff --git a/package-lock.json b/package-lock.json index 4e6f44dff..233b90811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "solid-server", - "version": "3.5.2", + "version": "3.5.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -5346,15 +5346,15 @@ "integrity": "sha1-OKwyu0izydyulTTIWrSGRAi92BY=" }, "rdflib": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.15.0.tgz", - "integrity": "sha1-onFe4Q8TN84rCw2hzTsQeZqu3gc=", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.16.2.tgz", + "integrity": "sha1-IAzOwaZxAQIVr5bp5s187eKyGrU=", "requires": { "async": "0.9.2", "jsonld": "0.4.12", "n3": "0.4.5", - "xmldom": "0.1.27", - "xmlhttprequest": "1.8.0" + "node-fetch": "1.7.2", + "xmldom": "0.1.27" }, "dependencies": { "async": { From 82af9ecd8d898e43857ae793c12a0231cfa8303d Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 18 Aug 2017 14:44:22 -0400 Subject: [PATCH 155/178] Implement fetchDocument without async. --- lib/handlers/allow.js | 52 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 62d26c70a..843cb4f63 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -3,7 +3,6 @@ module.exports = allow var ACL = require('../acl-checker') var $rdf = require('rdflib') var url = require('url') -var async = require('async') var utils = require('../utils') function allow (mode) { @@ -59,31 +58,30 @@ function allow (mode) { */ function fetchDocument (host, ldp, baseUri) { return function fetch (uri, callback) { - var graph = $rdf.graph() - async.waterfall([ - function readFile (cb) { - // If local request, slice off the initial baseUri - // S(uri).chompLeft(baseUri).s - var newPath = uri.startsWith(baseUri) - ? uri.slice(baseUri.length) - : uri - // Determine the root file system folder to look in - // TODO prettify this - var root = !ldp.idp ? ldp.root : ldp.root + host + '/' - // Derive the file path for the resource - var documentPath = utils.uriToFilename(newPath, root) - var documentUri = url.parse(documentPath) - documentPath = documentUri.pathname - return ldp.readFile(documentPath, cb) - }, - function parseFile (body, cb) { - try { - $rdf.parse(body, graph, uri, 'text/turtle') - } catch (err) { - return cb(err, graph) - } - return cb(null, graph) - } - ], callback) + readFile(uri, host, ldp, baseUri).then(body => { + const graph = $rdf.graph() + $rdf.parse(body, graph, uri, 'text/turtle') + return graph + }) + .then(graph => callback(null, graph), callback) } } + +// Reads the given file, returning its contents +function readFile (uri, host, ldp, baseUri) { + return new Promise((resolve, reject) => { + // If local request, slice off the initial baseUri + // S(uri).chompLeft(baseUri).s + var newPath = uri.startsWith(baseUri) + ? uri.slice(baseUri.length) + : uri + // Determine the root file system folder to look in + // TODO prettify this + var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + // Derive the file path for the resource + var documentPath = utils.uriToFilename(newPath, root) + var documentUri = url.parse(documentPath) + documentPath = documentUri.pathname + ldp.readFile(documentPath, (e, c) => e ? reject(e) : resolve(c)) + }) +} From de7b93adb8cd745cf23f8a15fb9dd4e7377afece Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Mon, 21 Aug 2017 12:14:55 -0400 Subject: [PATCH 156/178] Implement globHandler without async. --- lib/handlers/get.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/lib/handlers/get.js b/lib/handlers/get.js index 1005a0770..084bdafc1 100644 --- a/lib/handlers/get.js +++ b/lib/handlers/get.js @@ -5,7 +5,6 @@ var glob = require('glob') var _path = require('path') var $rdf = require('rdflib') var S = require('string') -var async = require('async') var Negotiator = require('negotiator') const url = require('url') const mime = require('mime-types') @@ -161,35 +160,28 @@ function globHandler (req, res, next) { let reqOrigin = utils.getBaseUri(req) debugGlob('found matches ' + matches) - async.each(matches, function (match, done) { + Promise.all(matches.map(match => new Promise((resolve, reject) => { var baseUri = utils.filenameToBaseUri(match, reqOrigin, root) fs.readFile(match, {encoding: 'utf8'}, function (err, fileData) { if (err) { debugGlob('error ' + err) - return done(null) + return resolve() } aclAllow(match, req, res, function (allowed) { if (!S(match).endsWith('.ttl') || !allowed) { - return done(null) + return resolve() } try { - $rdf.parse( - fileData, - globGraph, - baseUri, - 'text/turtle') + $rdf.parse(fileData, globGraph, baseUri, 'text/turtle') } catch (parseErr) { - debugGlob('error in parsing the files' + parseErr) + debugGlob(`error parsing ${match}: ${parseErr}`) } - return done(null) + return resolve() }) }) - }, function () { - var data = $rdf.serialize( - undefined, - globGraph, - requestUri, - 'text/turtle') + }))) + .then(() => { + var data = $rdf.serialize(undefined, globGraph, requestUri, 'text/turtle') // TODO this should be added as a middleware in the routes res.setHeader('Content-Type', 'text/turtle') debugGlob('returning turtle') From 67a6b48316749213cf009e54d693ae541cf9ec2c Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Mon, 21 Aug 2017 12:20:37 -0400 Subject: [PATCH 157/178] Implement CORS proxy test without async. --- test/integration/cors-proxy-test.js | 38 ++++++++++------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/test/integration/cors-proxy-test.js b/test/integration/cors-proxy-test.js index 69a5fa8c4..c79afea45 100644 --- a/test/integration/cors-proxy-test.js +++ b/test/integration/cors-proxy-test.js @@ -2,7 +2,6 @@ var assert = require('chai').assert var supertest = require('supertest') var path = require('path') var nock = require('nock') -var async = require('async') var ldnode = require('../../index') @@ -108,30 +107,19 @@ describe('CORS Proxy', () => { .expect(200, done) }) - it('should return the same HTTP status code as the uri', (done) => { - async.parallel([ - // 500 - (next) => { - nock('https://example.org').get('/404').reply(404) - server.get('/proxy/?uri=https://example.org/404') - .expect(404, next) - }, - (next) => { - nock('https://example.org').get('/401').reply(401) - server.get('/proxy/?uri=https://example.org/401') - .expect(401, next) - }, - (next) => { - nock('https://example.org').get('/500').reply(500) - server.get('/proxy/?uri=https://example.org/500') - .expect(500, next) - }, - (next) => { - nock('https://example.org').get('/').reply(200) - server.get('/proxy/?uri=https://example.org/') - .expect(200, next) - } - ], done) + it('should return the same HTTP status code as the uri', () => { + nock('https://example.org') + .get('/404').reply(404) + .get('/401').reply(401) + .get('/500').reply(500) + .get('/200').reply(200) + + return Promise.all([ + server.get('/proxy/?uri=https://example.org/404').expect(404), + server.get('/proxy/?uri=https://example.org/401').expect(401), + server.get('/proxy/?uri=https://example.org/500').expect(500), + server.get('/proxy/?uri=https://example.org/200').expect(200) + ]) }) it('should work with cors', (done) => { From ace60c77583b7e74058f95d14794c3e44eddb7cd Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Mon, 21 Aug 2017 13:55:24 -0400 Subject: [PATCH 158/178] Implement LDP without async. --- lib/ldp.js | 119 ++++++++++++++++++++++------------------------------- 1 file changed, 49 insertions(+), 70 deletions(-) diff --git a/lib/ldp.js b/lib/ldp.js index d16662f73..5b3522955 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -3,7 +3,6 @@ var path = require('path') const url = require('url') var fs = require('fs') var $rdf = require('rdflib') -var async = require('async') // var url = require('url') var mkdirp = require('fs-extra').mkdirp var uuid = require('uuid') @@ -13,7 +12,6 @@ var error = require('./http-error') var stringToStream = require('./utils').stringToStream var serialize = require('./utils').serialize var extend = require('extend') -var doWhilst = require('async').doWhilst var rimraf = require('rimraf') var ldpContainer = require('./ldp-container') var parse = require('./utils').parse @@ -156,42 +154,40 @@ class LDP { return callback(error(500, "Can't parse container")) } - async.waterfall( - [ - // add container stats - function (next) { - ldpContainer.addContainerStats(ldp, reqUri, filename, resourceGraph, next) - }, - // reading directory - function (next) { - ldpContainer.readdir(filename, next) - }, - // Iterate through all the files - function (files, next) { - async.each( - files, - function (file, cb) { - let fileUri = url.resolve(reqUri, encodeURIComponent(file)) - ldpContainer.addFile(ldp, resourceGraph, reqUri, fileUri, uri, - filename, file, cb) - }, - next) - } - ], - function (err, data) { + // add container stats + new Promise((resolve, reject) => + ldpContainer.addContainerStats(ldp, reqUri, filename, resourceGraph, + err => err ? reject(err) : resolve()) + ) + // read directory + .then(() => new Promise((resolve, reject) => + ldpContainer.readdir(filename, + (err, files) => err ? reject(err) : resolve(files)) + )) + // iterate through all the files + .then(files => { + return Promise.all(files.map(file => + new Promise((resolve, reject) => { + const fileUri = url.resolve(reqUri, encodeURIComponent(file)) + ldpContainer.addFile(ldp, resourceGraph, reqUri, fileUri, uri, + filename, file, err => err ? reject(err) : resolve()) + }) + )) + }) + .catch(() => { throw error(500, "Can't list container") }) + .then(() => new Promise((resolve, reject) => { + // TODO 'text/turtle' is fixed, should be contentType instead + // This forces one more translation turtle -> desired + serialize(resourceGraph, reqUri, 'text/turtle', function (err, result) { if (err) { - return callback(error(500, "Can't list container")) + debug.handlers('GET -- Error serializing container: ' + err) + reject(error(500, "Can't serialize container")) + } else { + resolve(result) } - // TODO 'text/turtle' is fixed, should be contentType instead - // This forces one more translation turtle -> desired - serialize(resourceGraph, reqUri, 'text/turtle', function (err, result) { - if (err) { - debug.handlers('GET -- Error serializing container: ' + err) - return callback(error(500, "Can't serialize container")) - } - return callback(null, result) - }) }) + })) + .then(result => callback(null, result), callback) } post (hostname, containerPath, slug, stream, container, callback) { @@ -351,18 +347,10 @@ class LDP { var root = ldp.idp ? ldp.root + host + '/' : ldp.root var filename = utils.uriToFilename(reqPath, root) - async.waterfall([ - // Read file - function (cb) { - return ldp.readFile(filename, cb) - }, - // Parse file - function (body, cb) { - parse(body, baseUri, contentType, function (err, graph) { - cb(err, graph) - }) - } - ], callback) + ldp.readFile(filename, (err, body) => { + if (err) return callback(err) + parse(body, baseUri, contentType, callback) + }) } get (options, callback) { @@ -395,7 +383,7 @@ class LDP { if (err) { metaFile = '' } - let absContainerUri = url.resolve(baseUri, reqPath) + let absContainerUri = baseUri + reqPath ldp.listContainer(filename, absContainerUri, baseUri, metaFile, contentType, function (err, data) { if (err) { @@ -498,34 +486,25 @@ class LDP { getAvailablePath (host, containerURI, slug, callback) { var self = this - var exists - - if (!slug) { - slug = uuid.v1() - } + slug = slug || uuid.v1() - var newPath = path.join(containerURI, slug) - - // TODO: maybe a nicer code - doWhilst( - function (next) { + function ensureNotExists (newPath) { + return new Promise(resolve => { self.exists(host, newPath, function (err) { - exists = !err - - if (exists) { - var id = uuid.v1().split('-')[ 0 ] + '-' + // If an error occurred, the resource does not exist yet + if (err) { + resolve(newPath) + // Otherwise, generate a new path + } else { + const id = uuid.v1().split('-')[ 0 ] + '-' newPath = path.join(containerURI, id + slug) + resolve(ensureNotExists(newPath)) } - - next() }) - }, - function () { - return exists === true - }, - function () { - callback(newPath) }) + } + + return ensureNotExists(path.join(containerURI, slug)).then(callback) } } module.exports = LDP From d54f95852ffde34bd7de9b3a0fc7b4c68b22f7a1 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Mon, 21 Aug 2017 14:05:36 -0400 Subject: [PATCH 159/178] Remove async dependency. --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 29957a291..cf545abca 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "homepage": "http://github.com/solid/node-solid-server", "bugs": "http://github.com/solid/node-solid-server/issues", "dependencies": { - "async": "^1.3.0", "body-parser": "^1.14.2", "busboy": "^0.2.12", "camelize": "^1.0.0", From 5746766014f35c203bd31cad1ef6091828df9f5c Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Mon, 21 Aug 2017 16:55:21 -0400 Subject: [PATCH 160/178] Expose WAC-Allow to browser clients. --- lib/create-app.js | 2 +- test/integration/header-test.js | 29 +++++++++++++++++++++++------ test/integration/http-test.js | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/create-app.js b/lib/create-app.js index 95546bf19..f02cc0ea3 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -26,7 +26,7 @@ const corsSettings = cors({ methods: [ 'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE' ], - exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length, WWW-Authenticate', + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate', credentials: true, maxAge: 1728000, origin: true, diff --git a/test/integration/header-test.js b/test/integration/header-test.js index 452857576..fc4cb6c75 100644 --- a/test/integration/header-test.js +++ b/test/integration/header-test.js @@ -23,17 +23,26 @@ describe('Header handler', () => { describe('WAC-Allow', () => { describeHeaderTest('read/append for the public', { resource: '/public-ra', - headers: { 'WAC-Allow': 'user="read append",public="read append"' } + headers: { + 'WAC-Allow': 'user="read append",public="read append"', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } }) describeHeaderTest('read/write for the user, read for the public', { resource: '/user-rw-public-r', - headers: { 'WAC-Allow': 'user="read write append",public="read"' } + headers: { + 'WAC-Allow': 'user="read write append",public="read"', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } }) describeHeaderTest('read/write/append/control for the user, nothing for the public', { resource: '/user-rwac-public-0', - headers: { 'WAC-Allow': 'user="read write append control",public=""' } + headers: { + 'WAC-Allow': 'user="read write append control",public=""', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } }) }) @@ -44,9 +53,17 @@ describe('Header handler', () => { for (const header in headers) { const value = headers[header] - it(`has a ${header} header of ${value}`, () => { - expect(response.headers).to.have.property(header.toLowerCase(), value) - }) + const name = header.toLowerCase() + if (value instanceof RegExp) { + it(`has a ${header} header matching ${value}`, () => { + expect(response.headers).to.have.property(name) + expect(response.headers[name]).to.match(value) + }) + } else { + it(`has a ${header} header of ${value}`, () => { + expect(response.headers).to.have.property(name, value) + }) + } } }) } diff --git a/test/integration/http-test.js b/test/integration/http-test.js index fb1fd92f5..df3871ab5 100644 --- a/test/integration/http-test.js +++ b/test/integration/http-test.js @@ -106,7 +106,7 @@ describe('HTTP APIs', function () { .expect('Access-Control-Allow-Origin', 'http://example.com') .expect('Access-Control-Allow-Credentials', 'true') .expect('Access-Control-Allow-Methods', 'OPTIONS,HEAD,GET,PATCH,POST,PUT,DELETE') - .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, Content-Length, WWW-Authenticate') + .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate') .expect(204, done) }) From 41da7334603b1b0b1f5faf58108d4385c02ce155 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 25 Aug 2017 12:10:30 -0400 Subject: [PATCH 161/178] Verify presence of test DNS entries. (#549) Closes #529. --- .../integration/account-creation-oidc-test.js | 4 +++- test/integration/acl-oidc-test.js | 4 +++- test/integration/cors-proxy-test.js | 3 +++ test/utils.js | 21 +++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/test/integration/account-creation-oidc-test.js b/test/integration/account-creation-oidc-test.js index 34997fc03..9bfdb7a79 100644 --- a/test/integration/account-creation-oidc-test.js +++ b/test/integration/account-creation-oidc-test.js @@ -2,7 +2,7 @@ const supertest = require('supertest') // Helper functions for the FS const $rdf = require('rdflib') -const { rm, read } = require('../utils') +const { rm, read, checkDnsSettings } = require('../utils') const ldnode = require('../../index') const path = require('path') const fs = require('fs-extra') @@ -26,6 +26,8 @@ describe('AccountManager (OIDC account creation tests)', function () { serverUri }) + before(checkDnsSettings) + before(function (done) { ldpHttpsServer = ldp.listen(3457, done) }) diff --git a/test/integration/acl-oidc-test.js b/test/integration/acl-oidc-test.js index 15078b032..20d7089ed 100644 --- a/test/integration/acl-oidc-test.js +++ b/test/integration/acl-oidc-test.js @@ -2,7 +2,7 @@ const assert = require('chai').assert const fs = require('fs-extra') const request = require('request') const path = require('path') -const { loadProvider, rm } = require('../utils') +const { loadProvider, rm, checkDnsSettings } = require('../utils') const IDToken = require('@trust/oidc-op/src/IDToken') const ldnode = require('../../index') @@ -60,6 +60,8 @@ const argv = { describe('ACL HTTP', function () { let ldp, ldpHttpsServer + before(checkDnsSettings) + before(done => { ldp = ldnode.createServer(argv) diff --git a/test/integration/cors-proxy-test.js b/test/integration/cors-proxy-test.js index c79afea45..7191d7af6 100644 --- a/test/integration/cors-proxy-test.js +++ b/test/integration/cors-proxy-test.js @@ -2,6 +2,7 @@ var assert = require('chai').assert var supertest = require('supertest') var path = require('path') var nock = require('nock') +var { checkDnsSettings } = require('../utils') var ldnode = require('../../index') @@ -13,6 +14,8 @@ describe('CORS Proxy', () => { }) var server = supertest(ldp) + before(checkDnsSettings) + it('should return the website in /proxy?uri', (done) => { nock('https://example.org').get('/').reply(200) server.get('/proxy?uri=https://example.org/') diff --git a/test/utils.js b/test/utils.js index 4e8205917..bb2db832a 100644 --- a/test/utils.js +++ b/test/utils.js @@ -3,6 +3,9 @@ var fsExtra = require('fs-extra') var rimraf = require('rimraf') var path = require('path') const OIDCProvider = require('@trust/oidc-op') +const dns = require('dns') + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] exports.rm = function (file) { return rimraf.sync(path.join(__dirname, '/resources/' + file)) @@ -35,6 +38,24 @@ exports.restore = function (src) { exports.rm(src + '.bak') } +// Verifies that all HOSTS entries are present +exports.checkDnsSettings = function () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || ip !== '127.0.0.1') { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + /** * @param configPath {string} * From 34ca3a69c6defe5bf1c139544215326b9bf01627 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Mon, 28 Aug 2017 11:40:31 -0400 Subject: [PATCH 162/178] Bump oidc-auth-manager dep to 0.12.0 --- package-lock.json | 46 ++++++++++++---------------------------------- package.json | 2 +- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 233b90811..03977fee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4851,9 +4851,9 @@ } }, "oidc-auth-manager": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.11.1.tgz", - "integrity": "sha512-P83EZ/muMqwyaXvGbnTTQSzx4gPaKhhjiGH5BOSTP2Kb0mlDT1QEOMKYkDS1frUi9ZkZBppVH7zKW4dyU3rAig==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.12.0.tgz", + "integrity": "sha512-Xg4n4vhxuQNSWWF0R3H6nglix2ufpBMpqJgw630RyPGeCsvuU8SLZRiBS5xafq4o7NwRyyE2uJ//znm60EW1Zw==", "requires": { "@trust/oidc-op": "0.3.0", "@trust/oidc-rs": "0.2.1", @@ -4868,11 +4868,6 @@ "valid-url": "1.0.9" }, "dependencies": { - "async": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", - "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=" - }, "fs-extra": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.1.tgz", @@ -4890,32 +4885,6 @@ "requires": { "graceful-fs": "4.1.11" } - }, - "rdflib": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.16.2.tgz", - "integrity": "sha1-IAzOwaZxAQIVr5bp5s187eKyGrU=", - "requires": { - "async": "0.9.2", - "jsonld": "0.4.12", - "n3": "0.4.5", - "node-fetch": "1.7.2", - "xmldom": "0.1.27" - } - }, - "solid-multi-rp-client": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.1.tgz", - "integrity": "sha1-NGinUYjv6KpfTE5+wUQ6cbgy0AM=", - "requires": { - "@trust/oidc-rp": "0.4.1", - "kvplus-files": "0.0.4" - } - }, - "xmldom": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", - "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" } } }, @@ -5767,6 +5736,15 @@ "@trust/oidc-rp": "0.4.1" } }, + "solid-multi-rp-client": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.1.tgz", + "integrity": "sha1-NGinUYjv6KpfTE5+wUQ6cbgy0AM=", + "requires": { + "@trust/oidc-rp": "0.4.1", + "kvplus-files": "0.0.4" + } + }, "solid-namespace": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/solid-namespace/-/solid-namespace-0.1.0.tgz", diff --git a/package.json b/package.json index cf545abca..aba418c19 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.11.1", + "oidc-auth-manager": "^0.12.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.16.2", "recursive-readdir": "^2.1.0", From 9a3022a7870e25aa4805d290785401f1c4573e56 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Mon, 28 Aug 2017 11:56:26 -0400 Subject: [PATCH 163/178] Expand error message for unverified web id --- lib/api/authn/webid-oidc.js | 9 +++++++-- lib/models/authenticator.js | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index b6bf8bbca..78766cdf6 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -6,7 +6,6 @@ const express = require('express') const bodyParser = require('body-parser').urlencoded({ extended: false }) const OidcManager = require('../../models/oidc-manager') - const { LoginRequest } = require('../../requests/login-request') const PasswordResetEmailRequest = require('../../requests/password-reset-email-request') @@ -48,7 +47,13 @@ function initialize (app, argv) { next() }) - .catch(next) + .catch(err => { + let error = new Error('Could not verify Web ID from token claims') + error.statusCode = 401 + error.cause = err + + next(error) + }) }) } diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index 08f65dbee..8b3a0dc2e 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -317,7 +317,7 @@ class TlsAuthenticator extends Authenticator { return Promise.resolve(this.accountManager.userAccountFrom({ webId })) } - debug(`WebID URI ${JSON.stringify(webId)} is not a local account, verifying preferred provider`) + debug(`WebID URI ${JSON.stringify(webId)} is not a local account, verifying authorized provider`) return this.discoverProviderFor(webId) .then(authorizedProvider => { From a710be5987d95e3ed7c23e335a745e93d72563c2 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 29 Aug 2017 16:39:50 -0400 Subject: [PATCH 164/178] Log whole error in error handler --- lib/handlers/error-pages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index f028190c0..b26242dce 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -15,7 +15,7 @@ const SELECT_PROVIDER_AUTH_METHODS = ['oidc'] * @param next {Function} */ function handler (err, req, res, next) { - debug('Error page because of ' + err.message) + debug('Error page because of ' + err) let locals = req.app.locals let authMethod = locals.authMethod From d0b474922b1e87546b3b25a3f7771cc5c4a16e6f Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 29 Aug 2017 21:01:31 -0400 Subject: [PATCH 165/178] Do not check for user header in oidc test --- test/integration/authentication-oidc-test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/integration/authentication-oidc-test.js b/test/integration/authentication-oidc-test.js index fddb49873..cd7a0a2ec 100644 --- a/test/integration/authentication-oidc-test.js +++ b/test/integration/authentication-oidc-test.js @@ -442,8 +442,6 @@ describe('Authentication API (OIDC)', () => { expect(res.status).to.equal(302) callbackUri = res.headers.get('location') expect(callbackUri.startsWith('https://app.example.com#')) - - expect(res.headers.get('user')).to.equal(aliceWebId) }) }) From 1c09007fbfc1f330c027894c28cc96ad23400a88 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Tue, 29 Aug 2017 21:34:32 -0400 Subject: [PATCH 166/178] Move RS options to oidc-auth-manager initialization --- lib/api/authn/webid-oidc.js | 5 +- package-lock.json | 398 +++++++++++++++++------------------- package.json | 2 +- 3 files changed, 195 insertions(+), 210 deletions(-) diff --git a/lib/api/authn/webid-oidc.js b/lib/api/authn/webid-oidc.js index 78766cdf6..41f46c890 100644 --- a/lib/api/authn/webid-oidc.js +++ b/lib/api/authn/webid-oidc.js @@ -32,10 +32,7 @@ function initialize (app, argv) { app.use('/', middleware(oidc)) // Perform the actual authentication - let rsOptions = { - allow: { audience: [app.locals.host.serverUri] } - } - app.use('/', oidc.rs.authenticate(rsOptions)) + app.use('/', oidc.rs.authenticate()) // Expose session.userId app.use('/', (req, res, next) => { diff --git a/package-lock.json b/package-lock.json index 03977fee7..3c740f4c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,23 +84,36 @@ } }, "@trust/oidc-rp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@trust/oidc-rp/-/oidc-rp-0.4.1.tgz", - "integrity": "sha512-ky6GQ+EuSTW4+9eylvwdIrPme1rvi42HnYiPDatGUN/CEgq89vuRzS4HNu+K78DXFEKt+8uvQZHF5/RDlsLXEg==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@trust/oidc-rp/-/oidc-rp-0.4.3.tgz", + "integrity": "sha512-xIpCLiIfjuVHfoZTlpmKSjBlcVCO9JxaO5oB+8dAd8uJvC0OHf0QyuTHiIO8riYbSyiqSjjBOYNMC9YIC/yAMA==", "requires": { "@trust/jose": "0.1.7", "@trust/json-document": "0.1.4", - "@trust/webcrypto": "0.3.0", + "@trust/webcrypto": "0.4.0", "base64url": "2.0.0", "node-fetch": "1.7.2", "text-encoding": "0.6.4", - "urlutils": "0.0.3" + "whatwg-url": "6.1.0" + }, + "dependencies": { + "@trust/webcrypto": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@trust/webcrypto/-/webcrypto-0.4.0.tgz", + "integrity": "sha1-zIcSyomn5x01P877ZrJwemec9jU=", + "requires": { + "@trust/keyto": "0.3.1", + "base64url": "2.0.0", + "node-rsa": "0.4.2", + "text-encoding": "0.6.4" + } + } } }, "@trust/oidc-rs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@trust/oidc-rs/-/oidc-rs-0.2.1.tgz", - "integrity": "sha1-sRawS3qF1hsNJMXkU5ej3dqbmnI=", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@trust/oidc-rs/-/oidc-rs-0.3.0.tgz", + "integrity": "sha512-fZp02qJwC1veWXL10TrwGHfbyLd3nz30kSBiUC5911UVOFAPuGOzcTkL0je6vCGO5F9aA8SZAGDHV4gCcs3enw==", "requires": { "@trust/jose": "0.1.7", "node-fetch": "1.7.2" @@ -118,9 +131,9 @@ } }, "accepts": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", - "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz", + "integrity": "sha1-hiRnWMfdbSGmR0/whKR0DsBesh8=", "requires": { "mime-types": "2.1.16", "negotiator": "0.6.1" @@ -297,7 +310,8 @@ "assertion-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz", - "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=" + "integrity": "sha1-E8pRXYYgbaC6xm6DTdOX2HWBCUw=", + "dev": true }, "astw": { "version": "2.2.0", @@ -328,9 +342,9 @@ "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" }, "babel-code-frame": { - "version": "6.22.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.22.0.tgz", - "integrity": "sha1-AnYgvuVnqIwyVhV05/0IAdMxGOQ=", + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", "dev": true, "requires": { "chalk": "1.1.3", @@ -493,7 +507,8 @@ "browser-stdout": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.0.tgz", - "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=" + "integrity": "sha1-81HTKWnTL6XXpVZxVCY9korjvR8=", + "dev": true }, "browserify": { "version": "14.4.0", @@ -616,7 +631,7 @@ "buffer-xor": "1.0.3", "cipher-base": "1.0.4", "create-hash": "1.1.3", - "evp_bytestokey": "1.0.0", + "evp_bytestokey": "1.0.2", "inherits": "2.0.3" } }, @@ -627,7 +642,7 @@ "requires": { "browserify-aes": "1.0.6", "browserify-des": "1.0.0", - "evp_bytestokey": "1.0.0" + "evp_bytestokey": "1.0.2" } }, "browserify-des": { @@ -773,31 +788,6 @@ "assertion-error": "1.0.2", "deep-eql": "0.1.3", "type-detect": "1.0.0" - }, - "dependencies": { - "deep-eql": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", - "dev": true, - "requires": { - "type-detect": "0.1.1" - }, - "dependencies": { - "type-detect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", - "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=", - "dev": true - } - } - }, - "type-detect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", - "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=", - "dev": true - } } }, "chai-as-promised": { @@ -824,7 +814,8 @@ "check-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", - "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=" + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true }, "cipher-base": { "version": "1.0.4", @@ -850,9 +841,9 @@ } }, "cli-width": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.1.0.tgz", - "integrity": "sha1-sjTKIJsp72b8UY2bmNWEewDt8Ao=" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=" }, "cliui": { "version": "2.1.0", @@ -901,13 +892,13 @@ "convert-source-map": "1.1.3", "inline-source-map": "0.6.2", "lodash.memoize": "3.0.4", - "source-map": "0.5.6" + "source-map": "0.5.7" }, "dependencies": { "source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=" + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" } } }, @@ -1108,7 +1099,7 @@ "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", "dev": true, "requires": { - "es5-ext": "0.10.27" + "es5-ext": "0.10.30" } }, "dashdash": { @@ -1152,17 +1143,19 @@ "optional": true }, "deep-eql": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-2.0.2.tgz", - "integrity": "sha1-sbrAblbwp2d3aG1Qyf63XC7XZ5o=", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", + "dev": true, "requires": { - "type-detect": "3.0.0" + "type-detect": "0.1.1" }, "dependencies": { "type-detect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-3.0.0.tgz", - "integrity": "sha1-RtDMhVOrt7E6NSsNbeov1Y8tm1U=" + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=", + "dev": true } } }, @@ -1200,7 +1193,7 @@ "requires": { "find-root": "1.1.0", "glob": "7.1.2", - "ignore": "3.3.3", + "ignore": "3.3.5", "pkg-config": "1.1.1", "run-parallel": "1.1.6", "uniq": "1.0.1" @@ -1277,7 +1270,8 @@ "diff": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz", - "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=" + "integrity": "sha1-yc45Okt8vQsFinJck98pkCeGj/k=", + "dev": true }, "diffie-hellman": { "version": "5.0.2", @@ -1411,9 +1405,9 @@ } }, "es5-ext": { - "version": "0.10.27", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.27.tgz", - "integrity": "sha512-3KXJRYzKXTd7xfFy5uZsJCXue55fAYQ035PRjyYk2PicllxIwcW9l3AbM/eGaw3vgVAUW4tl4xg9AXDEI6yw0w==", + "version": "0.10.30", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.30.tgz", + "integrity": "sha1-cUGhaDZpfbq/qq7uQUlc4p9SyTk=", "dev": true, "requires": { "es6-iterator": "2.0.1", @@ -1427,7 +1421,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27", + "es5-ext": "0.10.30", "es6-symbol": "3.1.1" } }, @@ -1438,7 +1432,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27", + "es5-ext": "0.10.30", "es6-iterator": "2.0.1", "es6-set": "0.1.5", "es6-symbol": "3.1.1", @@ -1457,7 +1451,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27", + "es5-ext": "0.10.30", "es6-iterator": "2.0.1", "es6-symbol": "3.1.1", "event-emitter": "0.3.5" @@ -1470,7 +1464,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27" + "es5-ext": "0.10.30" } }, "es6-weak-map": { @@ -1480,7 +1474,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27", + "es5-ext": "0.10.30", "es6-iterator": "2.0.1", "es6-symbol": "3.1.1" } @@ -1513,7 +1507,7 @@ "integrity": "sha1-yaEOi/bp1lZRIEd4xQM0Hx6sPOc=", "dev": true, "requires": { - "babel-code-frame": "6.22.0", + "babel-code-frame": "6.26.0", "chalk": "1.1.3", "concat-stream": "1.6.0", "debug": "2.6.8", @@ -1525,10 +1519,10 @@ "file-entry-cache": "2.0.0", "glob": "7.1.2", "globals": "9.18.0", - "ignore": "3.3.3", + "ignore": "3.3.5", "imurmurhash": "0.1.4", "inquirer": "0.12.0", - "is-my-json-valid": "2.16.0", + "is-my-json-valid": "2.16.1", "is-resolvable": "1.0.0", "js-yaml": "3.9.1", "json-stable-stringify": "1.0.1", @@ -1559,7 +1553,7 @@ "ansi-regex": "2.1.1", "chalk": "1.1.3", "cli-cursor": "1.0.2", - "cli-width": "2.1.0", + "cli-width": "2.2.0", "figures": "1.7.0", "lodash": "4.17.4", "readline2": "1.0.1", @@ -1673,7 +1667,7 @@ "dev": true, "requires": { "d": "1.0.0", - "es5-ext": "0.10.27" + "es5-ext": "0.10.30" } }, "eventemitter3": { @@ -1687,11 +1681,12 @@ "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" }, "evp_bytestokey": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.0.tgz", - "integrity": "sha1-SXtmrZ/vZc18CKYYCCS6FHa2blM=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.2.tgz", + "integrity": "sha512-ni0r0lrm7AOzsh2qC5mi9sj8S0gmj5fLNjfFpxN05FB4tAVZEKotbkjOtLPqTCX/CXT7NsUr6juZb4IFJeNNdA==", "requires": { - "create-hash": "1.1.3" + "md5.js": "1.3.4", + "safe-buffer": "5.1.1" } }, "exit-hook": { @@ -1720,7 +1715,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.15.4.tgz", "integrity": "sha1-Ay4iU0ic+PzgJma+yj0R7XotrtE=", "requires": { - "accepts": "1.3.3", + "accepts": "1.3.4", "array-flatten": "1.1.1", "content-disposition": "0.5.2", "content-type": "1.0.2", @@ -2019,9 +2014,9 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "function-bind": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.0.tgz", - "integrity": "sha1-FhdnFMgBeY5Ojyz391KUZ7tKV3E=" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "generate-function": { "version": "2.0.0", @@ -2038,11 +2033,6 @@ "is-property": "1.0.2" } }, - "get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=" - }, "get-stdin": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz", @@ -2152,12 +2142,14 @@ "graceful-readlink": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=" + "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", + "dev": true }, "growl": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", - "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=" + "integrity": "sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=", + "dev": true }, "handlebars": { "version": "4.0.10", @@ -2189,7 +2181,7 @@ "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", "requires": { - "function-bind": "1.1.0" + "function-bind": "1.1.1" } }, "has-ansi": { @@ -2208,7 +2200,8 @@ "has-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=" + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true }, "hash-base": { "version": "2.0.2", @@ -2267,15 +2260,6 @@ "integrity": "sha1-cVuW6phBWTzDMGeSP17GDr2k99c=", "dev": true }, - "deep-eql": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", - "dev": true, - "requires": { - "type-detect": "0.1.1" - } - }, "es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", @@ -2301,7 +2285,7 @@ "requires": { "chalk": "1.1.3", "commander": "2.11.0", - "is-my-json-valid": "2.16.0", + "is-my-json-valid": "2.16.1", "pinkie-promise": "2.0.1" } }, @@ -2311,12 +2295,6 @@ "integrity": "sha1-sEDrCSOWivq/jTL7HxfxFn/auQc=", "dev": true }, - "pathval": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-0.0.1.tgz", - "integrity": "sha1-EwBUw4dBU75Q/D/bTFuaHGgDbQg=", - "dev": true - }, "qs": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/qs/-/qs-0.6.6.tgz", @@ -2365,12 +2343,6 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz", "integrity": "sha1-Y3PbdpCf5XDgjXNYM2Xtgop07us=", "dev": true - }, - "type-detect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", - "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=", - "dev": true } } }, @@ -2461,9 +2433,9 @@ "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" }, "ignore": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.3.tgz", - "integrity": "sha1-QyNS5XrM2HqzEQ6C0/6g5HgSFW0=", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.5.tgz", + "integrity": "sha512-JLH93mL8amZQhh/p6mfQgVBH3M6epNq3DfsXsTSuSrInVjwyYlFE1nv2AgfRCC8PoOhM0jwQ5v8s9LgbK7yGDw==", "dev": true }, "imurmurhash": { @@ -2496,13 +2468,13 @@ "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz", "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=", "requires": { - "source-map": "0.5.6" + "source-map": "0.5.7" }, "dependencies": { "source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=" + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" } } }, @@ -2514,7 +2486,7 @@ "ansi-escapes": "1.4.0", "chalk": "1.1.3", "cli-cursor": "1.0.2", - "cli-width": "2.1.0", + "cli-width": "2.2.0", "external-editor": "1.1.1", "figures": "1.7.0", "lodash": "4.17.4", @@ -2649,9 +2621,9 @@ } }, "is-my-json-valid": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz", - "integrity": "sha1-8Hndm/2uZe4gOKrorLyGqxCeNpM=", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz", + "integrity": "sha512-ochPsqWS1WXj8ZnMIV0vnNXooaMhp7cyL4FMSIPKTtnV0Ha/T19G2b9kkhcNsabV9bxYkze7/aLZJb/bYuFduQ==", "dev": true, "requires": { "generate-function": "2.0.0", @@ -2800,7 +2772,8 @@ "json3": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz", - "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=" + "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", + "dev": true }, "jsonfile": { "version": "2.4.0", @@ -2821,7 +2794,7 @@ "integrity": "sha1-oC8gXVNBQU3xtthBTxuWenEgc+g=", "requires": { "es6-promise": "2.3.0", - "pkginfo": "0.4.0", + "pkginfo": "0.4.1", "request": "2.81.0", "xmldom": "0.1.19" } @@ -2940,6 +2913,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, "requires": { "lodash._basecopy": "3.0.1", "lodash.keys": "3.1.2" @@ -2948,27 +2922,32 @@ "lodash._basecopy": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", - "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=" + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true }, "lodash._basecreate": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz", - "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=" + "integrity": "sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=", + "dev": true }, "lodash._getnative": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=" + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true }, "lodash._isiterateecall": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", - "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=" + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true }, "lodash.create": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lodash.create/-/lodash.create-3.1.1.tgz", "integrity": "sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=", + "dev": true, "requires": { "lodash._baseassign": "3.2.0", "lodash._basecreate": "3.0.3", @@ -2978,17 +2957,20 @@ "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true }, "lodash.isarray": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=" + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true }, "lodash.keys": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, "requires": { "lodash._getnative": "3.9.1", "lodash.isarguments": "3.1.0", @@ -3003,8 +2985,7 @@ "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=" }, "lolex": { "version": "1.6.0", @@ -3017,6 +2998,26 @@ "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=" }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "requires": { + "hash-base": "3.0.4", + "inherits": "2.0.3" + }, + "dependencies": { + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "requires": { + "inherits": "2.0.3", + "safe-buffer": "5.1.1" + } + } + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3128,6 +3129,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, "requires": { "minimist": "0.0.8" }, @@ -3135,7 +3137,8 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true } } }, @@ -3143,6 +3146,7 @@ "version": "3.5.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-3.5.0.tgz", "integrity": "sha512-pIU2PJjrPYvYRqVpjXzj76qltO9uBYI7woYAMoxbSefsa+vqAfptjoeevd6bUgwD0mPIO+hv9f7ltvsNreL2PA==", + "dev": true, "requires": { "browser-stdout": "1.3.0", "commander": "2.9.0", @@ -3161,6 +3165,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=", + "dev": true, "requires": { "graceful-readlink": "1.0.1" } @@ -3169,6 +3174,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", + "dev": true, "requires": { "fs.realpath": "1.0.0", "inflight": "1.0.6", @@ -3182,6 +3188,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz", "integrity": "sha1-cqJiiU2dQIuVbKBf83su2KbiotU=", + "dev": true, "requires": { "has-flag": "1.0.0" } @@ -3350,7 +3357,7 @@ "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.6.4.tgz", "integrity": "sha1-ea47ym9HJ5HmGNqjOPu2KJJR0tc=", "requires": { - "accepts": "1.3.3", + "accepts": "1.3.4", "depd": "1.1.1", "fresh": "0.3.0", "merge-descriptors": "1.0.1", @@ -3418,7 +3425,7 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", "requires": { - "remove-trailing-separator": "1.0.2" + "remove-trailing-separator": "1.1.0" } }, "number-is-nan": { @@ -4837,7 +4844,7 @@ "integrity": "sha1-scnMBE7xuf5jYG/BQau7MuFHMMw=", "requires": { "define-properties": "1.1.2", - "function-bind": "1.1.0", + "function-bind": "1.1.1", "object-keys": "1.0.11" } }, @@ -4851,19 +4858,19 @@ } }, "oidc-auth-manager": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.12.0.tgz", - "integrity": "sha512-Xg4n4vhxuQNSWWF0R3H6nglix2ufpBMpqJgw630RyPGeCsvuU8SLZRiBS5xafq4o7NwRyyE2uJ//znm60EW1Zw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/oidc-auth-manager/-/oidc-auth-manager-0.13.0.tgz", + "integrity": "sha512-mpjNjqrdQ+QTEZiRzpiVA21FVGV+dDsR9ujrJzfjRwlK5ZuXiKDGu9HYKvEXNB3RuUYuhXcwiDCWDasYaIbd1g==", "requires": { "@trust/oidc-op": "0.3.0", - "@trust/oidc-rs": "0.2.1", + "@trust/oidc-rs": "0.3.0", "bcryptjs": "2.4.3", "fs-extra": "4.0.1", "kvplus-files": "0.0.4", "li": "1.2.1", "node-fetch": "1.7.2", "node-mocks-http": "1.6.4", - "rdflib": "0.16.2", + "rdflib": "0.16.3", "solid-multi-rp-client": "0.2.1", "valid-url": "1.0.9" }, @@ -5001,7 +5008,7 @@ "asn1.js": "4.9.1", "browserify-aes": "1.0.6", "create-hash": "1.1.3", - "evp_bytestokey": "1.0.0", + "evp_bytestokey": "1.0.2", "pbkdf2": "3.0.13" }, "dependencies": { @@ -5085,9 +5092,10 @@ "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, "pathval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", - "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=" + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-0.0.1.tgz", + "integrity": "sha1-EwBUw4dBU75Q/D/bTFuaHGgDbQg=", + "dev": true }, "pbkdf2": { "version": "3.0.13", @@ -5145,9 +5153,9 @@ } }, "pkginfo": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.0.tgz", - "integrity": "sha1-NJ27f/04CB/K3AhT32h/DHdEzWU=" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.4.1.tgz", + "integrity": "sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8=" }, "pluralize": { "version": "1.2.1", @@ -5315,9 +5323,9 @@ "integrity": "sha1-OKwyu0izydyulTTIWrSGRAi92BY=" }, "rdflib": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.16.2.tgz", - "integrity": "sha1-IAzOwaZxAQIVr5bp5s187eKyGrU=", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-0.16.3.tgz", + "integrity": "sha512-IfC7cVV4hLTwkAD17h03/hdeKggr0dY2lABF1N5BS++1VcylwwnQ9VZiTYInMaagnkpJllw+CzsVegCXGAyA1A==", "requires": { "async": "0.9.2", "jsonld": "0.4.12", @@ -5442,9 +5450,9 @@ } }, "remove-trailing-separator": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.0.2.tgz", - "integrity": "sha1-abBi2XhyetFNxrVrpKt3L9jXBRE=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" }, "repeat-element": { "version": "1.1.2", @@ -5704,6 +5712,12 @@ "requires": { "isarray": "0.0.1" } + }, + "type-detect": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.3.tgz", + "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=", + "dev": true } } }, @@ -5733,7 +5747,7 @@ "integrity": "sha1-lSvYt5UlYFtiqo9LEyKWMSD4Iw8=", "dev": true, "requires": { - "@trust/oidc-rp": "0.4.1" + "@trust/oidc-rp": "0.4.3" } }, "solid-multi-rp-client": { @@ -5741,7 +5755,7 @@ "resolved": "https://registry.npmjs.org/solid-multi-rp-client/-/solid-multi-rp-client-0.2.1.tgz", "integrity": "sha1-NGinUYjv6KpfTE5+wUQ6cbgy0AM=", "requires": { - "@trust/oidc-rp": "0.4.1", + "@trust/oidc-rp": "0.4.3", "kvplus-files": "0.0.4" } }, @@ -6080,9 +6094,9 @@ } }, "superagent": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.5.2.tgz", - "integrity": "sha1-M2GjlxVnUEw1EGOr6q4PqiPb8/g=", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.6.0.tgz", + "integrity": "sha512-oWsu4mboo8sVxagp4bNwZIR1rUmypeAJDmNIwT9mF4k06hSu6P92aOjEWLaIj7vsX3fOUp+cRH/04tao+q5Q7A==", "dev": true, "requires": { "component-emitter": "1.2.1", @@ -6092,7 +6106,7 @@ "form-data": "2.1.4", "formidable": "1.1.1", "methods": "1.1.2", - "mime": "1.3.4", + "mime": "1.4.0", "qs": "6.4.0", "readable-stream": "2.3.3" }, @@ -6103,6 +6117,12 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, + "mime": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.0.tgz", + "integrity": "sha512-n9ChLv77+QQEapYz8lV+rIZAW3HhAPW2CXnzb1GN5uMkuczshwvkW7XPsbzU0ZQN3sP47Er2KVkp2p3KyqZKSQ==", + "dev": true + }, "readable-stream": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", @@ -6136,7 +6156,7 @@ "dev": true, "requires": { "methods": "1.1.2", - "superagent": "3.5.2" + "superagent": "3.6.0" } }, "supports-color": { @@ -6285,8 +6305,7 @@ "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", - "dev": true + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" }, "tryit": { "version": "1.0.3", @@ -6323,9 +6342,10 @@ } }, "type-detect": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.3.tgz", - "integrity": "sha1-Dj8mcLRAmbC0bChNE2p+9Jx0wuo=" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", + "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=", + "dev": true }, "type-is": { "version": "1.6.15", @@ -6347,15 +6367,15 @@ "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", "optional": true, "requires": { - "source-map": "0.5.6", + "source-map": "0.5.7", "uglify-to-browserify": "1.0.2", "yargs": "3.10.0" }, "dependencies": { "source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", "optional": true } } @@ -6410,11 +6430,6 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" }, - "URIjs": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/URIjs/-/URIjs-1.16.1.tgz", - "integrity": "sha1-7evGeLi3SyawXStIHhI4P1rgS4s=" - }, "url": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", @@ -6431,31 +6446,6 @@ } } }, - "urlutils": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/urlutils/-/urlutils-0.0.3.tgz", - "integrity": "sha1-aw9e2ibjY1Jc7hpnkJippO/nrd4=", - "requires": { - "chai": "4.1.1", - "mocha": "3.5.0", - "URIjs": "1.16.1" - }, - "dependencies": { - "chai": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.1.tgz", - "integrity": "sha1-ZuISeebzxkFf+CMYeCJ5AOIXGzk=", - "requires": { - "assertion-error": "1.0.2", - "check-error": "1.0.2", - "deep-eql": "2.0.2", - "get-func-name": "2.0.0", - "pathval": "1.1.0", - "type-detect": "4.0.3" - } - } - } - }, "user-home": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", @@ -6569,14 +6559,12 @@ "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" }, "whatwg-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.1.0.tgz", "integrity": "sha1-X8gnm5PXVIO5ztiyYjmFSEehhXg=", - "dev": true, "requires": { "lodash.sortby": "4.7.0", "tr46": "0.0.3", diff --git a/package.json b/package.json index aba418c19..346599fec 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "node-forge": "^0.6.38", "nodemailer": "^3.1.4", "nomnom": "^1.8.1", - "oidc-auth-manager": "^0.12.0", + "oidc-auth-manager": "^0.13.0", "oidc-op-express": "^0.0.3", "rdflib": "^0.16.2", "recursive-readdir": "^2.1.0", From c8e9109cb7ace16560bba7eafbc7ef4f3f0bec26 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Fri, 18 Aug 2017 10:33:16 -0400 Subject: [PATCH 167/178] Add support for external WebIDs registering with username & password --- default-templates/new-account/index.html | 4 +- default-templates/new-account/profile/card | 7 +- default-views/account/register-form.hbs | 24 +- lib/handlers/error-pages.js | 2 +- lib/models/account-manager.js | 26 +- lib/models/user-account.js | 4 + package-lock.json | 1244 ++++++++++---------- test/unit/account-manager-test.js | 51 +- 8 files changed, 713 insertions(+), 649 deletions(-) diff --git a/default-templates/new-account/index.html b/default-templates/new-account/index.html index 6c5abd03c..6696f05fb 100644 --- a/default-templates/new-account/index.html +++ b/default-templates/new-account/index.html @@ -14,10 +14,10 @@ <h3>Solid User Profile</h3> <div class="row"> <div class="col-md-12"> <p style="margin-top: 3em; margin-bottom: 3em;"> - Welcome to your Solid user profile. + Welcome. </p> <p> - Your Web ID is:<br /> + Web ID:<br /> <code>{{webId}}</code> </p> diff --git a/default-templates/new-account/profile/card b/default-templates/new-account/profile/card index ce337b0a4..8ba3bd28b 100644 --- a/default-templates/new-account/profile/card +++ b/default-templates/new-account/profile/card @@ -6,10 +6,10 @@ <> a foaf:PersonalProfileDocument ; - foaf:maker <#me> ; - foaf:primaryTopic <#me> . + foaf:maker <{{webId}}> ; + foaf:primaryTopic <{{webId}}> . -<#me> +<{{webId}}> a foaf:Person ; a schema:Person ; @@ -18,6 +18,7 @@ solid:account </> ; # link to the account uri pim:storage </> ; # root storage + solid:inbox </inbox/> ; ldp:inbox </inbox/> ; pim:preferencesFile </settings/prefs.ttl> ; # private settings/preferences diff --git a/default-views/account/register-form.hbs b/default-views/account/register-form.hbs index d0d721022..524bcf0cd 100644 --- a/default-views/account/register-form.hbs +++ b/default-views/account/register-form.hbs @@ -8,28 +8,42 @@ </div> {{/if}} <div class="row"> - <div class="col-md-12"> + <div class="col-md-6"> <label for="username">Username:</label> <input type="text" class="form-control" name="username" id="username" placeholder="alice" /> </div> + <div class="col-md-6"> </div> </div> <div class="row"> - <div class="col-md-12"> + <div class="col-md-6"> <label for="password">Password:</label> <input type="password" class="form-control" name="password" id="password" /> </div> + <div class="col-md-6"> </div> </div> <div class="row"> - <div class="col-md-12"> + <div class="col-md-6"> <label for="name">Name:</label> - <input type="name" class="form-control" name="name" id="name" /> + <input type="text" class="form-control" name="name" id="name" /> </div> + <div class="col-md-6"> </div> </div> <div class="row"> - <div class="col-md-12"> + <div class="col-md-6"> <label for="email">Email:</label> + <p>Your email will only used for account recovery</p> <input type="email" class="form-control" name="email" id="email" /> </div> + <div class="col-md-6"> </div> + </div> + <div class="row"> + <div class="col-md-6"> + <label for="externalWebId">(Optional) External WebID:</label> + <p>We will generate a Web ID when you register, but if you + already have a Web ID hosted elsewhere, enter it here</p> + <input type="text" class="form-control" name="externalWebId" id="externalWebId" /> + </div> + <div class="col-md-6"> </div> </div> <input type="hidden" name="returnToUrl" value="{{returnToUrl}}" /> </div> diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index b26242dce..5ae42b1ea 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -15,7 +15,7 @@ const SELECT_PROVIDER_AUTH_METHODS = ['oidc'] * @param next {Function} */ function handler (err, req, res, next) { - debug('Error page because of ' + err) + debug('Error page because of:', err) let locals = req.app.locals let authMethod = locals.authMethod diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index 8bfc94e0b..d891ee074 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -346,16 +346,32 @@ class AccountManager { email: userData.email, name: userData.name, externalWebId: userData.externalWebId, - webId: userData.webid || userData.webId || - this.accountWebIdFor(userData.username) + localAccountId: userData.localAccountId, + webId: userData.webid || userData.webId || userData.externalWebId } - if (!userConfig.username) { - if (!userConfig.webId) { + try { + userConfig.webId = userConfig.webId || this.accountWebIdFor(userConfig.username) + } catch (err) { + if (err.message === 'Cannot construct uri for blank account name') { throw new Error('Username or web id is required') + } else { + throw err } + } - userConfig.username = this.usernameFromWebId(userConfig.webId) + if (userConfig.username) { + if (userConfig.externalWebId && !userConfig.localAccountId) { + // External Web ID exists, derive the local account id from username + userConfig.localAccountId = this.accountWebIdFor(userConfig.username) + .split('//')[1] // drop the https:// + } + } else { // no username - derive it from web id + if (userConfig.externalWebId) { + userConfig.username = userConfig.externalWebId + } else { + userConfig.username = this.usernameFromWebId(userConfig.webId) + } } return UserAccount.from(userConfig) diff --git a/lib/models/user-account.js b/lib/models/user-account.js index 5c9b56fa5..6fec8967f 100644 --- a/lib/models/user-account.js +++ b/lib/models/user-account.js @@ -13,12 +13,16 @@ class UserAccount { * @param [options.webId] {string} * @param [options.name] {string} * @param [options.email] {string} + * @param [options.externalWebId] {string} + * @param [options.localAccountId] {string} */ constructor (options = {}) { this.username = options.username this.webId = options.webId this.name = options.name this.email = options.email + this.externalWebId = options.externalWebId + this.localAccountId = options.localAccountId } /** diff --git a/package-lock.json b/package-lock.json index 3c740f4c7..067fc138a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3439,1384 +3439,1384 @@ "integrity": "sha1-8n9NkfKp2zbCT1dP9cbv/wIz3kY=", "dev": true, "requires": { - "archy": "1.0.0", - "arrify": "1.0.1", - "caching-transform": "1.0.1", - "convert-source-map": "1.5.0", - "debug-log": "1.0.1", - "default-require-extensions": "1.0.0", - "find-cache-dir": "0.1.1", - "find-up": "1.1.2", - "foreground-child": "1.5.6", - "glob": "7.1.1", - "istanbul-lib-coverage": "1.1.0", - "istanbul-lib-hook": "1.0.6", - "istanbul-lib-instrument": "1.7.1", - "istanbul-lib-report": "1.1.0", - "istanbul-lib-source-maps": "1.2.0", - "istanbul-reports": "1.1.0", - "md5-hex": "1.3.0", - "merge-source-map": "1.0.3", - "micromatch": "2.3.11", - "mkdirp": "0.5.1", - "resolve-from": "2.0.0", - "rimraf": "2.6.1", - "signal-exit": "3.0.2", - "spawn-wrap": "1.2.4", - "test-exclude": "4.1.0", - "yargs": "7.1.0", - "yargs-parser": "5.0.0" + "archy": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "arrify": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "caching-transform": "https://registry.npmjs.org/caching-transform/-/caching-transform-1.0.1.tgz", + "convert-source-map": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.0.tgz", + "debug-log": "https://registry.npmjs.org/debug-log/-/debug-log-1.0.1.tgz", + "default-require-extensions": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", + "find-cache-dir": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz", + "find-up": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "foreground-child": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", + "glob": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "istanbul-lib-coverage": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz", + "istanbul-lib-hook": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.0.6.tgz", + "istanbul-lib-instrument": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.7.1.tgz", + "istanbul-lib-report": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.0.tgz", + "istanbul-lib-source-maps": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.0.tgz", + "istanbul-reports": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.1.0.tgz", + "md5-hex": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz", + "merge-source-map": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.3.tgz", + "micromatch": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolve-from": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "rimraf": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "signal-exit": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "spawn-wrap": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-1.2.4.tgz", + "test-exclude": "https://registry.npmjs.org/test-exclude/-/test-exclude-4.1.0.tgz", + "yargs": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "yargs-parser": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz" }, "dependencies": { "align-text": { - "version": "0.1.4", - "bundled": true, + "version": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", "dev": true, "requires": { - "kind-of": "3.2.0", - "longest": "1.0.1", - "repeat-string": "1.6.1" + "kind-of": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz", + "longest": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "repeat-string": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz" } }, "amdefine": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", "dev": true }, "ansi-regex": { - "version": "2.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true }, "ansi-styles": { - "version": "2.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", "dev": true }, "append-transform": { - "version": "0.4.0", - "bundled": true, + "version": "https://registry.npmjs.org/append-transform/-/append-transform-0.4.0.tgz", + "integrity": "sha1-126/jKlNJ24keja61EpLdKthGZE=", "dev": true, "requires": { - "default-require-extensions": "1.0.0" + "default-require-extensions": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz" } }, "archy": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", "dev": true }, "arr-diff": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "dev": true, "requires": { - "arr-flatten": "1.0.3" + "arr-flatten": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.3.tgz" } }, "arr-flatten": { - "version": "1.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.3.tgz", + "integrity": "sha1-onTthawIhJtr14R8RYB0XcUa37E=", "dev": true }, "array-unique": { - "version": "0.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", "dev": true }, "arrify": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, "async": { - "version": "1.5.2", - "bundled": true, + "version": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true }, "babel-code-frame": { - "version": "6.22.0", - "bundled": true, + "version": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.22.0.tgz", + "integrity": "sha1-AnYgvuVnqIwyVhV05/0IAdMxGOQ=", "dev": true, "requires": { - "chalk": "1.1.3", - "esutils": "2.0.2", - "js-tokens": "3.0.1" + "chalk": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "esutils": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "js-tokens": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.1.tgz" } }, "babel-generator": { - "version": "6.24.1", - "bundled": true, + "version": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.24.1.tgz", + "integrity": "sha1-5xX0hsWN7SVknYiJRNUqoHxdlJc=", "dev": true, "requires": { - "babel-messages": "6.23.0", - "babel-runtime": "6.23.0", - "babel-types": "6.24.1", - "detect-indent": "4.0.0", - "jsesc": "1.3.0", - "lodash": "4.17.4", - "source-map": "0.5.6", - "trim-right": "1.0.1" + "babel-messages": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "babel-runtime": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz", + "babel-types": "https://registry.npmjs.org/babel-types/-/babel-types-6.24.1.tgz", + "detect-indent": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "jsesc": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "trim-right": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz" } }, "babel-messages": { - "version": "6.23.0", - "bundled": true, + "version": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", "dev": true, "requires": { - "babel-runtime": "6.23.0" + "babel-runtime": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz" } }, "babel-runtime": { - "version": "6.23.0", - "bundled": true, + "version": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz", + "integrity": "sha1-CpSJ8UTecO+zzkMArM2zKeL8VDs=", "dev": true, "requires": { - "core-js": "2.4.1", - "regenerator-runtime": "0.10.5" + "core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz", + "regenerator-runtime": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz" } }, "babel-template": { - "version": "6.24.1", - "bundled": true, + "version": "https://registry.npmjs.org/babel-template/-/babel-template-6.24.1.tgz", + "integrity": "sha1-BK5RTx+Ts6JTfyoPYKWkX7gwgzM=", "dev": true, "requires": { - "babel-runtime": "6.23.0", - "babel-traverse": "6.24.1", - "babel-types": "6.24.1", - "babylon": "6.17.0", - "lodash": "4.17.4" + "babel-runtime": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz", + "babel-traverse": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.24.1.tgz", + "babel-types": "https://registry.npmjs.org/babel-types/-/babel-types-6.24.1.tgz", + "babylon": "https://registry.npmjs.org/babylon/-/babylon-6.17.0.tgz", + "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz" } }, "babel-traverse": { - "version": "6.24.1", - "bundled": true, + "version": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.24.1.tgz", + "integrity": "sha1-qzZnP9NW+aCUhlnnszjV/q2zFpU=", "dev": true, "requires": { - "babel-code-frame": "6.22.0", - "babel-messages": "6.23.0", - "babel-runtime": "6.23.0", - "babel-types": "6.24.1", - "babylon": "6.17.0", - "debug": "2.6.6", - "globals": "9.17.0", - "invariant": "2.2.2", - "lodash": "4.17.4" + "babel-code-frame": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.22.0.tgz", + "babel-messages": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "babel-runtime": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz", + "babel-types": "https://registry.npmjs.org/babel-types/-/babel-types-6.24.1.tgz", + "babylon": "https://registry.npmjs.org/babylon/-/babylon-6.17.0.tgz", + "debug": "https://registry.npmjs.org/debug/-/debug-2.6.6.tgz", + "globals": "https://registry.npmjs.org/globals/-/globals-9.17.0.tgz", + "invariant": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", + "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz" } }, "babel-types": { - "version": "6.24.1", - "bundled": true, + "version": "https://registry.npmjs.org/babel-types/-/babel-types-6.24.1.tgz", + "integrity": "sha1-oTaHncFbNga9oNkMH8dDBML/CXU=", "dev": true, "requires": { - "babel-runtime": "6.23.0", - "esutils": "2.0.2", - "lodash": "4.17.4", - "to-fast-properties": "1.0.3" + "babel-runtime": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz", + "esutils": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "to-fast-properties": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz" } }, "babylon": { - "version": "6.17.0", - "bundled": true, + "version": "https://registry.npmjs.org/babylon/-/babylon-6.17.0.tgz", + "integrity": "sha1-N9qUiHhIi5xOPEA4iT+jMUs/yTI=", "dev": true }, "balanced-match": { - "version": "0.4.2", - "bundled": true, + "version": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=", "dev": true }, "brace-expansion": { - "version": "1.1.7", - "bundled": true, + "version": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz", + "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=", "dev": true, "requires": { - "balanced-match": "0.4.2", - "concat-map": "0.0.1" + "balanced-match": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "concat-map": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" } }, "braces": { - "version": "1.8.5", - "bundled": true, + "version": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", "dev": true, "requires": { - "expand-range": "1.8.2", - "preserve": "0.2.0", - "repeat-element": "1.1.2" + "expand-range": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "preserve": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "repeat-element": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz" } }, "builtin-modules": { - "version": "1.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", "dev": true }, "caching-transform": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/caching-transform/-/caching-transform-1.0.1.tgz", + "integrity": "sha1-bb2y8g+Nj7znnz6U6dF0Lc31wKE=", "dev": true, "requires": { - "md5-hex": "1.3.0", - "mkdirp": "0.5.1", - "write-file-atomic": "1.3.4" + "md5-hex": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "write-file-atomic": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz" } }, "camelcase": { - "version": "1.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", "dev": true, "optional": true }, "center-align": { - "version": "0.1.3", - "bundled": true, + "version": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", "dev": true, "optional": true, "requires": { - "align-text": "0.1.4", - "lazy-cache": "1.0.4" + "align-text": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "lazy-cache": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz" } }, "chalk": { - "version": "1.1.3", - "bundled": true, + "version": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { - "ansi-styles": "2.2.1", - "escape-string-regexp": "1.0.5", - "has-ansi": "2.0.0", - "strip-ansi": "3.0.1", - "supports-color": "2.0.0" + "ansi-styles": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "escape-string-regexp": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "has-ansi": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "strip-ansi": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "supports-color": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" } }, "cliui": { - "version": "2.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", "dev": true, "optional": true, "requires": { - "center-align": "0.1.3", - "right-align": "0.1.3", - "wordwrap": "0.0.2" + "center-align": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "right-align": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "wordwrap": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" }, "dependencies": { "wordwrap": { - "version": "0.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", "dev": true, "optional": true } } }, "code-point-at": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true }, "commondir": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, "concat-map": { - "version": "0.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, "convert-source-map": { - "version": "1.5.0", - "bundled": true, + "version": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.0.tgz", + "integrity": "sha1-ms1whRxtXf3ZPZKC5e35SgP/RrU=", "dev": true }, "core-js": { - "version": "2.4.1", - "bundled": true, + "version": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz", + "integrity": "sha1-TekR5mew6ukSTjQlS1OupvxhjT4=", "dev": true }, "cross-spawn": { - "version": "4.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", "dev": true, "requires": { - "lru-cache": "4.0.2", - "which": "1.2.14" + "lru-cache": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "which": "https://registry.npmjs.org/which/-/which-1.2.14.tgz" } }, "debug": { - "version": "2.6.6", - "bundled": true, + "version": "https://registry.npmjs.org/debug/-/debug-2.6.6.tgz", + "integrity": "sha1-qfpvvpykPPHnn3O3XAGJy7fW21o=", "dev": true, "requires": { - "ms": "0.7.3" + "ms": "https://registry.npmjs.org/ms/-/ms-0.7.3.tgz" } }, "debug-log": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/debug-log/-/debug-log-1.0.1.tgz", + "integrity": "sha1-IwdjLUwEOCuN+KMvcLiVBG1SdF8=", "dev": true }, "decamelize": { - "version": "1.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", "dev": true }, "default-require-extensions": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", + "integrity": "sha1-836hXT4T/9m0N9M+GnW1+5eHTLg=", "dev": true, "requires": { - "strip-bom": "2.0.0" + "strip-bom": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz" } }, "detect-indent": { - "version": "4.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", "dev": true, "requires": { - "repeating": "2.0.1" + "repeating": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz" } }, "error-ex": { - "version": "1.3.1", - "bundled": true, + "version": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz", + "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=", "dev": true, "requires": { - "is-arrayish": "0.2.1" + "is-arrayish": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" } }, "escape-string-regexp": { - "version": "1.0.5", - "bundled": true, + "version": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true }, "esutils": { - "version": "2.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, "expand-brackets": { - "version": "0.1.5", - "bundled": true, + "version": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", "dev": true, "requires": { - "is-posix-bracket": "0.1.1" + "is-posix-bracket": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz" } }, "expand-range": { - "version": "1.8.2", - "bundled": true, + "version": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=", "dev": true, "requires": { - "fill-range": "2.2.3" + "fill-range": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz" } }, "extglob": { - "version": "0.3.2", - "bundled": true, + "version": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", "dev": true, "requires": { - "is-extglob": "1.0.0" + "is-extglob": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz" } }, "filename-regex": { - "version": "2.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=", "dev": true }, "fill-range": { - "version": "2.2.3", - "bundled": true, + "version": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz", + "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=", "dev": true, "requires": { - "is-number": "2.1.0", - "isobject": "2.1.0", - "randomatic": "1.1.6", - "repeat-element": "1.1.2", - "repeat-string": "1.6.1" + "is-number": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "isobject": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "randomatic": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.6.tgz", + "repeat-element": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "repeat-string": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz" } }, "find-cache-dir": { - "version": "0.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz", + "integrity": "sha1-yN765XyKUqinhPnjHFfHQumToLk=", "dev": true, "requires": { - "commondir": "1.0.1", - "mkdirp": "0.5.1", - "pkg-dir": "1.0.0" + "commondir": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "pkg-dir": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz" } }, "find-up": { - "version": "1.1.2", - "bundled": true, + "version": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", "dev": true, "requires": { - "path-exists": "2.1.0", - "pinkie-promise": "2.0.1" + "path-exists": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "pinkie-promise": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" } }, "for-in": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", "dev": true }, "for-own": { - "version": "0.1.5", - "bundled": true, + "version": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=", "dev": true, "requires": { - "for-in": "1.0.2" + "for-in": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz" } }, "foreground-child": { - "version": "1.5.6", - "bundled": true, + "version": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", + "integrity": "sha1-T9ca0t/elnibmApcCilZN8svXOk=", "dev": true, "requires": { - "cross-spawn": "4.0.2", - "signal-exit": "3.0.2" + "cross-spawn": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "signal-exit": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz" } }, "fs.realpath": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, "get-caller-file": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=", "dev": true }, "glob": { - "version": "7.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "integrity": "sha1-gFIR3wT6rxxjo2ADBs31reULLsg=", "dev": true, "requires": { - "fs.realpath": "1.0.0", - "inflight": "1.0.6", - "inherits": "2.0.3", - "minimatch": "3.0.3", - "once": "1.4.0", - "path-is-absolute": "1.0.1" + "fs.realpath": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "inflight": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "minimatch": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", + "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "path-is-absolute": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" } }, "glob-base": { - "version": "0.3.0", - "bundled": true, + "version": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=", "dev": true, "requires": { - "glob-parent": "2.0.0", - "is-glob": "2.0.1" + "glob-parent": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "is-glob": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" } }, "glob-parent": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", + "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=", "dev": true, "requires": { - "is-glob": "2.0.1" + "is-glob": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" } }, "globals": { - "version": "9.17.0", - "bundled": true, + "version": "https://registry.npmjs.org/globals/-/globals-9.17.0.tgz", + "integrity": "sha1-DAymltm5u2lNLlRwvTd3fKrVAoY=", "dev": true }, "graceful-fs": { - "version": "4.1.11", - "bundled": true, + "version": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", "dev": true }, "handlebars": { - "version": "4.0.8", - "bundled": true, + "version": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.8.tgz", + "integrity": "sha1-Irh1zT8ObL6jAxTxROgrx6cv9CA=", "dev": true, "requires": { - "async": "1.5.2", - "optimist": "0.6.1", - "source-map": "0.4.4", - "uglify-js": "2.8.22" + "async": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "optimist": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "uglify-js": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.22.tgz" }, "dependencies": { "source-map": { - "version": "0.4.4", - "bundled": true, + "version": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", "dev": true, "requires": { - "amdefine": "1.0.1" + "amdefine": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" } } } }, "has-ansi": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", "dev": true, "requires": { - "ansi-regex": "2.1.1" + "ansi-regex": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz" } }, "has-flag": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", "dev": true }, "hosted-git-info": { - "version": "2.4.2", - "bundled": true, + "version": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.4.2.tgz", + "integrity": "sha1-AHa59GonBQbduq6lZJaJdGBhKmc=", "dev": true }, "imurmurhash": { - "version": "0.1.4", - "bundled": true, + "version": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, "inflight": { - "version": "1.0.6", - "bundled": true, + "version": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "requires": { - "once": "1.4.0", - "wrappy": "1.0.2" + "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" } }, "inherits": { - "version": "2.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true }, "invariant": { - "version": "2.2.2", - "bundled": true, + "version": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", + "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=", "dev": true, "requires": { - "loose-envify": "1.3.1" + "loose-envify": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz" } }, "invert-kv": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", "dev": true }, "is-arrayish": { - "version": "0.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", "dev": true }, "is-buffer": { - "version": "1.1.5", - "bundled": true, + "version": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz", + "integrity": "sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw=", "dev": true }, "is-builtin-module": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", "dev": true, "requires": { - "builtin-modules": "1.1.1" + "builtin-modules": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz" } }, "is-dotfile": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.2.tgz", + "integrity": "sha1-LBMjg/ORmfjtwmjKAbmwB9IFzE0=", "dev": true }, "is-equal-shallow": { - "version": "0.1.3", - "bundled": true, + "version": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=", "dev": true, "requires": { - "is-primitive": "2.0.0" + "is-primitive": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz" } }, "is-extendable": { - "version": "0.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", "dev": true }, "is-extglob": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=", "dev": true }, "is-finite": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", "dev": true, "requires": { - "number-is-nan": "1.0.1" + "number-is-nan": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz" } }, "is-fullwidth-code-point": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "requires": { - "number-is-nan": "1.0.1" + "number-is-nan": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz" } }, "is-glob": { - "version": "2.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "dev": true, "requires": { - "is-extglob": "1.0.0" + "is-extglob": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz" } }, "is-number": { - "version": "2.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=", "dev": true, "requires": { - "kind-of": "3.2.0" + "kind-of": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz" } }, "is-posix-bracket": { - "version": "0.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", + "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=", "dev": true }, "is-primitive": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz", + "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU=", "dev": true }, "is-utf8": { - "version": "0.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", "dev": true }, "isarray": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, "isexe": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, "isobject": { - "version": "2.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", "dev": true, "requires": { - "isarray": "1.0.0" + "isarray": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" } }, "istanbul-lib-coverage": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz", + "integrity": "sha1-ysoZ3srvNSW11jMdcB8/O3rUhSg=", "dev": true }, "istanbul-lib-hook": { - "version": "1.0.6", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.0.6.tgz", + "integrity": "sha1-wIZtHoHPLVMZJJUQEx/Bbe5JIx8=", "dev": true, "requires": { - "append-transform": "0.4.0" + "append-transform": "https://registry.npmjs.org/append-transform/-/append-transform-0.4.0.tgz" } }, "istanbul-lib-instrument": { - "version": "1.7.1", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.7.1.tgz", + "integrity": "sha1-Fp4xvGLHeIUamUOd2Zw8wSGE02A=", "dev": true, "requires": { - "babel-generator": "6.24.1", - "babel-template": "6.24.1", - "babel-traverse": "6.24.1", - "babel-types": "6.24.1", - "babylon": "6.17.0", - "istanbul-lib-coverage": "1.1.0", - "semver": "5.3.0" + "babel-generator": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.24.1.tgz", + "babel-template": "https://registry.npmjs.org/babel-template/-/babel-template-6.24.1.tgz", + "babel-traverse": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.24.1.tgz", + "babel-types": "https://registry.npmjs.org/babel-types/-/babel-types-6.24.1.tgz", + "babylon": "https://registry.npmjs.org/babylon/-/babylon-6.17.0.tgz", + "istanbul-lib-coverage": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz", + "semver": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz" } }, "istanbul-lib-report": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.0.tgz", + "integrity": "sha1-RExOzKmvqTz1hPVrEPGVv3aMB3A=", "dev": true, "requires": { - "istanbul-lib-coverage": "1.1.0", - "mkdirp": "0.5.1", - "path-parse": "1.0.5", - "supports-color": "3.2.3" + "istanbul-lib-coverage": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "path-parse": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "supports-color": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz" }, "dependencies": { "supports-color": { - "version": "3.2.3", - "bundled": true, + "version": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", "dev": true, "requires": { - "has-flag": "1.0.0" + "has-flag": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz" } } } }, "istanbul-lib-source-maps": { - "version": "1.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.0.tgz", + "integrity": "sha1-jHcG1Jfib+62rz4MKP1bBmlZjQ4=", "dev": true, "requires": { - "debug": "2.6.6", - "istanbul-lib-coverage": "1.1.0", - "mkdirp": "0.5.1", - "rimraf": "2.6.1", - "source-map": "0.5.6" + "debug": "https://registry.npmjs.org/debug/-/debug-2.6.6.tgz", + "istanbul-lib-coverage": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "rimraf": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" } }, "istanbul-reports": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.1.0.tgz", + "integrity": "sha1-HvO3lYiSGc+1+tFjZfbOEI1fjGY=", "dev": true, "requires": { - "handlebars": "4.0.8" + "handlebars": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.8.tgz" } }, "js-tokens": { - "version": "3.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.1.tgz", + "integrity": "sha1-COnxMkhKLEWjCQfp3E1VZ7fxFNc=", "dev": true }, "jsesc": { - "version": "1.3.0", - "bundled": true, + "version": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", "dev": true }, "kind-of": { - "version": "3.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz", + "integrity": "sha1-tYq+TVwEStM3JqjBUltIz4kb/wc=", "dev": true, "requires": { - "is-buffer": "1.1.5" + "is-buffer": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz" } }, "lazy-cache": { - "version": "1.0.4", - "bundled": true, + "version": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", "dev": true, "optional": true }, "lcid": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", "dev": true, "requires": { - "invert-kv": "1.0.0" + "invert-kv": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz" } }, "load-json-file": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "dev": true, "requires": { - "graceful-fs": "4.1.11", - "parse-json": "2.2.0", - "pify": "2.3.0", - "pinkie-promise": "2.0.1", - "strip-bom": "2.0.0" + "graceful-fs": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "parse-json": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "pify": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "pinkie-promise": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "strip-bom": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz" } }, "lodash": { - "version": "4.17.4", - "bundled": true, + "version": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", "dev": true }, "longest": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", "dev": true }, "loose-envify": { - "version": "1.3.1", - "bundled": true, + "version": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", "dev": true, "requires": { - "js-tokens": "3.0.1" + "js-tokens": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.1.tgz" } }, "lru-cache": { - "version": "4.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=", "dev": true, "requires": { - "pseudomap": "1.0.2", - "yallist": "2.1.2" + "pseudomap": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "yallist": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz" } }, "md5-hex": { - "version": "1.3.0", - "bundled": true, + "version": "https://registry.npmjs.org/md5-hex/-/md5-hex-1.3.0.tgz", + "integrity": "sha1-0sSv6YPENwZiF5uMrRRSGRNQRsQ=", "dev": true, "requires": { - "md5-o-matic": "0.1.1" + "md5-o-matic": "https://registry.npmjs.org/md5-o-matic/-/md5-o-matic-0.1.1.tgz" } }, "md5-o-matic": { - "version": "0.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/md5-o-matic/-/md5-o-matic-0.1.1.tgz", + "integrity": "sha1-givM1l4RfFFPqxdrJZRdVBAKA8M=", "dev": true }, "merge-source-map": { - "version": "1.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.3.tgz", + "integrity": "sha1-2hQV8nIqURnbB7FMT5c0EIY6Kr8=", "dev": true, "requires": { - "source-map": "0.5.6" + "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" } }, "micromatch": { - "version": "2.3.11", - "bundled": true, + "version": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", "dev": true, "requires": { - "arr-diff": "2.0.0", - "array-unique": "0.2.1", - "braces": "1.8.5", - "expand-brackets": "0.1.5", - "extglob": "0.3.2", - "filename-regex": "2.0.1", - "is-extglob": "1.0.0", - "is-glob": "2.0.1", - "kind-of": "3.2.0", - "normalize-path": "2.1.1", - "object.omit": "2.0.1", - "parse-glob": "3.0.4", - "regex-cache": "0.4.3" + "arr-diff": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", + "array-unique": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz", + "braces": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", + "expand-brackets": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", + "extglob": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", + "filename-regex": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", + "is-extglob": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "is-glob": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "kind-of": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz", + "normalize-path": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "object.omit": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "parse-glob": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "regex-cache": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz" } }, "minimatch": { - "version": "3.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=", "dev": true, "requires": { - "brace-expansion": "1.1.7" + "brace-expansion": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz" } }, "minimist": { - "version": "0.0.8", - "bundled": true, + "version": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true }, "mkdirp": { - "version": "0.5.1", - "bundled": true, + "version": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "requires": { - "minimist": "0.0.8" + "minimist": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" } }, "ms": { - "version": "0.7.3", - "bundled": true, + "version": "https://registry.npmjs.org/ms/-/ms-0.7.3.tgz", + "integrity": "sha1-cIFVpeROM/X9D8U+gdDUCpG+H/8=", "dev": true }, "normalize-package-data": { - "version": "2.3.8", - "bundled": true, + "version": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.3.8.tgz", + "integrity": "sha1-2Bntoqne29H/pWPqQHHZNngilbs=", "dev": true, "requires": { - "hosted-git-info": "2.4.2", - "is-builtin-module": "1.0.0", - "semver": "5.3.0", - "validate-npm-package-license": "3.0.1" + "hosted-git-info": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.4.2.tgz", + "is-builtin-module": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "semver": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "validate-npm-package-license": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz" } }, "normalize-path": { - "version": "2.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", "dev": true, "requires": { - "remove-trailing-separator": "1.0.1" + "remove-trailing-separator": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.0.1.tgz" } }, "number-is-nan": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true }, "object-assign": { - "version": "4.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true }, "object.omit": { - "version": "2.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", + "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=", "dev": true, "requires": { - "for-own": "0.1.5", - "is-extendable": "0.1.1" + "for-own": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "is-extendable": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz" } }, "once": { - "version": "1.4.0", - "bundled": true, + "version": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "requires": { - "wrappy": "1.0.2" + "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" } }, "optimist": { - "version": "0.6.1", - "bundled": true, + "version": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", "dev": true, "requires": { - "minimist": "0.0.8", - "wordwrap": "0.0.3" + "minimist": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "wordwrap": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" } }, "os-homedir": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, "os-locale": { - "version": "1.4.0", - "bundled": true, + "version": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "dev": true, "requires": { - "lcid": "1.0.0" + "lcid": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz" } }, "parse-glob": { - "version": "3.0.4", - "bundled": true, + "version": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", + "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=", "dev": true, "requires": { - "glob-base": "0.3.0", - "is-dotfile": "1.0.2", - "is-extglob": "1.0.0", - "is-glob": "2.0.1" + "glob-base": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz", + "is-dotfile": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.2.tgz", + "is-extglob": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "is-glob": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" } }, "parse-json": { - "version": "2.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", "dev": true, "requires": { - "error-ex": "1.3.1" + "error-ex": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz" } }, "path-exists": { - "version": "2.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", "dev": true, "requires": { - "pinkie-promise": "2.0.1" + "pinkie-promise": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" } }, "path-is-absolute": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, "path-parse": { - "version": "1.0.5", - "bundled": true, + "version": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", "dev": true }, "path-type": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", "dev": true, "requires": { - "graceful-fs": "4.1.11", - "pify": "2.3.0", - "pinkie-promise": "2.0.1" + "graceful-fs": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "pify": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "pinkie-promise": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" } }, "pify": { - "version": "2.3.0", - "bundled": true, + "version": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, "pinkie": { - "version": "2.0.4", - "bundled": true, + "version": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", "dev": true }, "pinkie-promise": { - "version": "2.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", "dev": true, "requires": { - "pinkie": "2.0.4" + "pinkie": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" } }, "pkg-dir": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz", + "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=", "dev": true, "requires": { - "find-up": "1.1.2" + "find-up": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz" } }, "preserve": { - "version": "0.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", + "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=", "dev": true }, "pseudomap": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, "randomatic": { - "version": "1.1.6", - "bundled": true, + "version": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.6.tgz", + "integrity": "sha1-EQ3Kv/OX6dz/fAeJzMCkmt8exbs=", "dev": true, "requires": { - "is-number": "2.1.0", - "kind-of": "3.2.0" + "is-number": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "kind-of": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz" } }, "read-pkg": { - "version": "1.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", "dev": true, "requires": { - "load-json-file": "1.1.0", - "normalize-package-data": "2.3.8", - "path-type": "1.1.0" + "load-json-file": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "normalize-package-data": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.3.8.tgz", + "path-type": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz" } }, "read-pkg-up": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", "dev": true, "requires": { - "find-up": "1.1.2", - "read-pkg": "1.1.0" + "find-up": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "read-pkg": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz" } }, "regenerator-runtime": { - "version": "0.10.5", - "bundled": true, + "version": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=", "dev": true }, "regex-cache": { - "version": "0.4.3", - "bundled": true, + "version": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz", + "integrity": "sha1-mxpsNdTQ3871cRrmUejp09cRQUU=", "dev": true, "requires": { - "is-equal-shallow": "0.1.3", - "is-primitive": "2.0.0" + "is-equal-shallow": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz", + "is-primitive": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz" } }, "remove-trailing-separator": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.0.1.tgz", + "integrity": "sha1-YV67lq9VlVLUv0BXyENtSGq2PMQ=", "dev": true }, "repeat-element": { - "version": "1.1.2", - "bundled": true, + "version": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz", + "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo=", "dev": true }, "repeat-string": { - "version": "1.6.1", - "bundled": true, + "version": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", "dev": true }, "repeating": { - "version": "2.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", "dev": true, "requires": { - "is-finite": "1.0.2" + "is-finite": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz" } }, "require-directory": { - "version": "2.1.1", - "bundled": true, + "version": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, "require-main-filename": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", "dev": true }, "resolve-from": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=", "dev": true }, "right-align": { - "version": "0.1.3", - "bundled": true, + "version": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", "dev": true, "optional": true, "requires": { - "align-text": "0.1.4" + "align-text": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz" } }, "rimraf": { - "version": "2.6.1", - "bundled": true, + "version": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "integrity": "sha1-wjOOxkPfeht/5cVPqG9XQopV8z0=", "dev": true, "requires": { - "glob": "7.1.1" + "glob": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" } }, "semver": { - "version": "5.3.0", - "bundled": true, + "version": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true }, "set-blocking": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true }, "signal-exit": { - "version": "3.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, "slide": { - "version": "1.1.6", - "bundled": true, + "version": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz", + "integrity": "sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=", "dev": true }, "source-map": { - "version": "0.5.6", - "bundled": true, + "version": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=", "dev": true }, "spawn-wrap": { - "version": "1.2.4", - "bundled": true, + "version": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-1.2.4.tgz", + "integrity": "sha1-kg6yEadpwJPuv71bDnpdLmirLkA=", "dev": true, "requires": { - "foreground-child": "1.5.6", - "mkdirp": "0.5.1", - "os-homedir": "1.0.2", - "rimraf": "2.6.1", - "signal-exit": "2.1.2", - "which": "1.2.14" + "foreground-child": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", + "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "os-homedir": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "rimraf": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.1.tgz", + "signal-exit": "https://registry.npmjs.org/signal-exit/-/signal-exit-2.1.2.tgz", + "which": "https://registry.npmjs.org/which/-/which-1.2.14.tgz" }, "dependencies": { "signal-exit": { - "version": "2.1.2", - "bundled": true, + "version": "https://registry.npmjs.org/signal-exit/-/signal-exit-2.1.2.tgz", + "integrity": "sha1-N1h5sfkuvDszRIDQONxUam1VhWQ=", "dev": true } } }, "spdx-correct": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz", + "integrity": "sha1-SzBz2TP/UfORLwOsVRlJikFQ20A=", "dev": true, "requires": { - "spdx-license-ids": "1.2.2" + "spdx-license-ids": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz" } }, "spdx-expression-parse": { - "version": "1.0.4", - "bundled": true, + "version": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz", + "integrity": "sha1-m98vIOH0DtRH++JzJmGR/O1RYmw=", "dev": true }, "spdx-license-ids": { - "version": "1.2.2", - "bundled": true, + "version": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz", + "integrity": "sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc=", "dev": true }, "string-width": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "requires": { - "code-point-at": "1.1.0", - "is-fullwidth-code-point": "1.0.0", - "strip-ansi": "3.0.1" + "code-point-at": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "is-fullwidth-code-point": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "strip-ansi": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" } }, "strip-ansi": { - "version": "3.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "requires": { - "ansi-regex": "2.1.1" + "ansi-regex": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz" } }, "strip-bom": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", "dev": true, "requires": { - "is-utf8": "0.2.1" + "is-utf8": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz" } }, "supports-color": { - "version": "2.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true }, "test-exclude": { - "version": "4.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/test-exclude/-/test-exclude-4.1.0.tgz", + "integrity": "sha1-BMpwtzkN04yY1KADoXOAbKeZHJE=", "dev": true, "requires": { - "arrify": "1.0.1", - "micromatch": "2.3.11", - "object-assign": "4.1.1", - "read-pkg-up": "1.0.1", - "require-main-filename": "1.0.1" + "arrify": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "micromatch": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", + "object-assign": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "read-pkg-up": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "require-main-filename": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz" } }, "to-fast-properties": { - "version": "1.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", "dev": true }, "trim-right": { - "version": "1.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, "uglify-js": { - "version": "2.8.22", - "bundled": true, + "version": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.22.tgz", + "integrity": "sha1-1Uk0d4qNoUkD+imjJvskwKtRoaA=", "dev": true, "optional": true, "requires": { - "source-map": "0.5.6", - "uglify-to-browserify": "1.0.2", - "yargs": "3.10.0" + "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "uglify-to-browserify": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "yargs": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz" }, "dependencies": { "yargs": { - "version": "3.10.0", - "bundled": true, + "version": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", "dev": true, "optional": true, "requires": { - "camelcase": "1.2.1", - "cliui": "2.1.0", - "decamelize": "1.2.0", - "window-size": "0.1.0" + "camelcase": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "cliui": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "decamelize": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "window-size": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" } } } }, "uglify-to-browserify": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", "dev": true, "optional": true }, "validate-npm-package-license": { - "version": "3.0.1", - "bundled": true, + "version": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz", + "integrity": "sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=", "dev": true, "requires": { - "spdx-correct": "1.0.2", - "spdx-expression-parse": "1.0.4" + "spdx-correct": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz", + "spdx-expression-parse": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz" } }, "which": { - "version": "1.2.14", - "bundled": true, + "version": "https://registry.npmjs.org/which/-/which-1.2.14.tgz", + "integrity": "sha1-mofEN48D6CfOyvGs31bHNsAcFOU=", "dev": true, "requires": { - "isexe": "2.0.0" + "isexe": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" } }, "which-module": { - "version": "1.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", "dev": true }, "window-size": { - "version": "0.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", "dev": true, "optional": true }, "wordwrap": { - "version": "0.0.3", - "bundled": true, + "version": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", "dev": true }, "wrap-ansi": { - "version": "2.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "dev": true, "requires": { - "string-width": "1.0.2", - "strip-ansi": "3.0.1" + "string-width": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "strip-ansi": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" } }, "wrappy": { - "version": "1.0.2", - "bundled": true, + "version": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, "write-file-atomic": { - "version": "1.3.4", - "bundled": true, + "version": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-1.3.4.tgz", + "integrity": "sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=", "dev": true, "requires": { - "graceful-fs": "4.1.11", - "imurmurhash": "0.1.4", - "slide": "1.1.6" + "graceful-fs": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "imurmurhash": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "slide": "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz" } }, "y18n": { - "version": "3.2.1", - "bundled": true, + "version": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", "dev": true }, "yallist": { - "version": "2.1.2", - "bundled": true, + "version": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", "dev": true }, "yargs": { - "version": "7.1.0", - "bundled": true, + "version": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", "dev": true, "requires": { - "camelcase": "3.0.0", - "cliui": "3.2.0", - "decamelize": "1.2.0", - "get-caller-file": "1.0.2", - "os-locale": "1.4.0", - "read-pkg-up": "1.0.1", - "require-directory": "2.1.1", - "require-main-filename": "1.0.1", - "set-blocking": "2.0.0", - "string-width": "1.0.2", - "which-module": "1.0.0", - "y18n": "3.2.1", - "yargs-parser": "5.0.0" + "camelcase": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "cliui": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "decamelize": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "get-caller-file": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz", + "os-locale": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "read-pkg-up": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "require-directory": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "require-main-filename": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "set-blocking": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "string-width": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "which-module": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "y18n": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "yargs-parser": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz" }, "dependencies": { "camelcase": { - "version": "3.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", "dev": true }, "cliui": { - "version": "3.2.0", - "bundled": true, + "version": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", "dev": true, "requires": { - "string-width": "1.0.2", - "strip-ansi": "3.0.1", - "wrap-ansi": "2.1.0" + "string-width": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "strip-ansi": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "wrap-ansi": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz" } } } }, "yargs-parser": { - "version": "5.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", "dev": true, "requires": { - "camelcase": "3.0.0" + "camelcase": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz" }, "dependencies": { "camelcase": { - "version": "3.0.0", - "bundled": true, + "version": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", "dev": true } } diff --git a/test/unit/account-manager-test.js b/test/unit/account-manager-test.js index 1bb0453bc..9080cb9b7 100644 --- a/test/unit/account-manager-test.js +++ b/test/unit/account-manager-test.js @@ -125,33 +125,62 @@ describe('AccountManager', () => { describe('userAccountFrom()', () => { describe('in multi user mode', () => { let multiUser = true + let options, accountManager - it('should throw an error if no username is passed', () => { - let options = { host, multiUser } - let accountManager = AccountManager.from(options) + beforeEach(() => { + options = { host, multiUser } + accountManager = AccountManager.from(options) + }) + it('should throw an error if no username is passed', () => { expect(() => { accountManager.userAccountFrom({}) - }).to.throw(Error) + }).to.throw(/Username or web id is required/) }) it('should init webId from param if no username is passed', () => { - let options = { host, multiUser } - let accountManager = AccountManager.from(options) + let userData = { webId: 'https://example.com' } + let newAccount = accountManager.userAccountFrom(userData) + expect(newAccount.webId).to.equal(userData.webId) + }) + + it('should derive the local account id from username, for external webid', () => { + let userData = { + externalWebId: 'https://alice.external.com/profile#me', + username: 'user1' + } - let userData = { webid: 'https://example.com' } let newAccount = accountManager.userAccountFrom(userData) - expect(newAccount.webId).to.equal(userData.webid) + + expect(newAccount.username).to.equal('user1') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.localAccountId).to.equal('user1.example.com/profile/card#me') + }) + + it('should use the external web id as username if no username given', () => { + let userData = { + externalWebId: 'https://alice.external.com/profile#me' + } + + let newAccount = accountManager.userAccountFrom(userData) + + expect(newAccount.username).to.equal('https://alice.external.com/profile#me') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') }) }) describe('in single user mode', () => { let multiUser = false + let options, accountManager - it('should not throw an error if no username is passed', () => { - let options = { host, multiUser } - let accountManager = AccountManager.from(options) + beforeEach(() => { + options = { host, multiUser } + accountManager = AccountManager.from(options) + }) + it('should not throw an error if no username is passed', () => { expect(() => { accountManager.userAccountFrom({}) }).to.not.throw(Error) From 8b1dc11557416744d2872b49f172bdd208cadf19 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 30 Aug 2017 13:59:47 -0400 Subject: [PATCH 168/178] Remove solid:inbox from template --- default-templates/new-account/profile/card | 1 - 1 file changed, 1 deletion(-) diff --git a/default-templates/new-account/profile/card b/default-templates/new-account/profile/card index 8ba3bd28b..063bc61cf 100644 --- a/default-templates/new-account/profile/card +++ b/default-templates/new-account/profile/card @@ -18,7 +18,6 @@ solid:account </> ; # link to the account uri pim:storage </> ; # root storage - solid:inbox </inbox/> ; ldp:inbox </inbox/> ; pim:preferencesFile </settings/prefs.ttl> ; # private settings/preferences From 49fb741f1b4e90895c53d9618eeae0b88e49b0d4 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 30 Aug 2017 14:01:54 -0400 Subject: [PATCH 169/178] Tweak account index page phrasing --- default-templates/new-account/index.html | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/default-templates/new-account/index.html b/default-templates/new-account/index.html index 6696f05fb..68e6e858d 100644 --- a/default-templates/new-account/index.html +++ b/default-templates/new-account/index.html @@ -14,12 +14,7 @@ <h3>Solid User Profile</h3> <div class="row"> <div class="col-md-12"> <p style="margin-top: 3em; margin-bottom: 3em;"> - Welcome. - </p> - <p> - Web ID:<br /> - - <code>{{webId}}</code> + Welcome to the account of <code>{{webId}}</code> </p> </div> </div> From c868ab1222776f89b173942176e735c8edb0b051 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 30 Aug 2017 15:45:05 -0400 Subject: [PATCH 170/178] Serve static common/ dir relative to __dirname --- lib/create-app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/create-app.js b/lib/create-app.js index f02cc0ea3..f621d840f 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -21,6 +21,7 @@ const config = require('./server-config') const defaults = require('../config/defaults') const options = require('./handlers/options') const debug = require('./debug').authentication +const path = require('path') const corsSettings = cors({ methods: [ @@ -51,7 +52,7 @@ function createApp (argv = {}) { initViews(app, configPath) // Serve the public 'common' directory (for shared CSS files, etc) - app.use('/common', express.static('common')) + app.use('/common', express.static(path.join(__dirname, '../common'))) // Add CORS proxy if (argv.proxy) { From 0dfc619b535990741018311f40bafd3c4c38eb43 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 30 Aug 2017 18:15:06 -0400 Subject: [PATCH 171/178] Disable rejectUnauthorized on the WebID-TLS endpoint. (#561) --- bin/lib/options.js | 2 +- lib/models/authenticator.js | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/lib/options.js b/bin/lib/options.js index 222f90014..85581a97f 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -117,7 +117,7 @@ module.exports = [ }, { name: 'no-reject-unauthorized', - help: 'Accepts clients with invalid certificates (set for testing WebID-TLS)', + help: 'Accept self-signed certificates', flag: true, default: false, prompt: false diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index 8b3a0dc2e..a9e3bef7e 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -228,7 +228,11 @@ class TlsAuthenticator extends Authenticator { let connection = this.connection return new Promise((resolve, reject) => { - connection.renegotiate({ requestCert: true }, (error) => { + // Typically, certificates for WebID-TLS are not signed or self-signed, + // and would hence be rejected by Node.js for security reasons. + // However, since WebID-TLS instead dereferences the profile URL to validate ownership, + // we can safely skip the security check. + connection.renegotiate({ requestCert: true, rejectUnauthorized: false }, (error) => { if (error) { debug('Error renegotiating TLS:', error) From bc58c7d37122f255fd9be5df064f738a6536246a Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Wed, 30 Aug 2017 19:48:43 -0400 Subject: [PATCH 172/178] Add current hash to redirect. (#562) --- lib/handlers/error-pages.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/handlers/error-pages.js b/lib/handlers/error-pages.js index 5ae42b1ea..ab8908672 100644 --- a/lib/handlers/error-pages.js +++ b/lib/handlers/error-pages.js @@ -188,7 +188,7 @@ function redirectBody (url) { return `<!DOCTYPE HTML> <meta charset="UTF-8"> <script> - window.location.href = "${url}" + window.location.href = "${url}" + window.location.hash </script> <noscript> <meta http-equiv="refresh" content="0; url=${url}"> From cc569a8588bd7166f46deb4261b41d0c53e5c29f Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Wed, 30 Aug 2017 17:26:24 -0400 Subject: [PATCH 173/178] Add bootstrap.min.css.map to common/css/ --- common/css/bootstrap.min.css.map | 1 + 1 file changed, 1 insertion(+) create mode 100644 common/css/bootstrap.min.css.map diff --git a/common/css/bootstrap.min.css.map b/common/css/bootstrap.min.css.map new file mode 100644 index 000000000..6c7fa40b9 --- /dev/null +++ b/common/css/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["less/normalize.less","less/print.less","bootstrap.css","dist/css/bootstrap.css","less/glyphicons.less","less/scaffolding.less","less/mixins/vendor-prefixes.less","less/mixins/tab-focus.less","less/mixins/image.less","less/type.less","less/mixins/text-emphasis.less","less/mixins/background-variant.less","less/mixins/text-overflow.less","less/code.less","less/grid.less","less/mixins/grid.less","less/mixins/grid-framework.less","less/tables.less","less/mixins/table-row.less","less/forms.less","less/mixins/forms.less","less/buttons.less","less/mixins/buttons.less","less/mixins/opacity.less","less/component-animations.less","less/dropdowns.less","less/mixins/nav-divider.less","less/mixins/reset-filter.less","less/button-groups.less","less/mixins/border-radius.less","less/input-groups.less","less/navs.less","less/navbar.less","less/mixins/nav-vertical-align.less","less/utilities.less","less/breadcrumbs.less","less/pagination.less","less/mixins/pagination.less","less/pager.less","less/labels.less","less/mixins/labels.less","less/badges.less","less/jumbotron.less","less/thumbnails.less","less/alerts.less","less/mixins/alerts.less","less/progress-bars.less","less/mixins/gradients.less","less/mixins/progress-bar.less","less/media.less","less/list-group.less","less/mixins/list-group.less","less/panels.less","less/mixins/panels.less","less/responsive-embed.less","less/wells.less","less/close.less","less/modals.less","less/tooltip.less","less/mixins/reset-text.less","less/popovers.less","less/carousel.less","less/mixins/clearfix.less","less/mixins/center-block.less","less/mixins/hide-text.less","less/responsive-utilities.less","less/mixins/responsive-visibility.less"],"names":[],"mappings":";;;;4EAQA,KACE,YAAA,WACA,yBAAA,KACA,qBAAA,KAOF,KACE,OAAA,EAaF,QAAA,MAAA,QAAA,WAAA,OAAA,OAAA,OAAA,OAAA,KAAA,KAAA,IAAA,QAAA,QAaE,QAAA,MAQF,MAAA,OAAA,SAAA,MAIE,QAAA,aACA,eAAA,SAQF,sBACE,QAAA,KACA,OAAA,EAQF,SAAA,SAEE,QAAA,KAUF,EACE,iBAAA,YAQF,SAAA,QAEE,QAAA,EAUF,YACE,cAAA,IAAA,OAOF,EAAA,OAEE,YAAA,IAOF,IACE,WAAA,OAQF,GACE,OAAA,MAAA,EACA,UAAA,IAOF,KACE,MAAA,KACA,WAAA,KAOF,MACE,UAAA,IAOF,IAAA,IAEE,SAAA,SACA,UAAA,IACA,YAAA,EACA,eAAA,SAGF,IACE,IAAA,MAGF,IACE,OAAA,OAUF,IACE,OAAA,EAOF,eACE,SAAA,OAUF,OACE,OAAA,IAAA,KAOF,GACE,OAAA,EAAA,mBAAA,YAAA,gBAAA,YACA,WAAA,YAOF,IACE,SAAA,KAOF,KAAA,IAAA,IAAA,KAIE,YAAA,UAAA,UACA,UAAA,IAkBF,OAAA,MAAA,SAAA,OAAA,SAKE,OAAA,EACA,KAAA,QACA,MAAA,QAOF,OACE,SAAA,QAUF,OAAA,OAEE,eAAA,KAWF,OAAA,wBAAA,kBAAA,mBAIE,mBAAA,OACA,OAAA,QAOF,iBAAA,qBAEE,OAAA,QAOF,yBAAA,wBAEE,QAAA,EACA,OAAA,EAQF,MACE,YAAA,OAWF,qBAAA,kBAEE,mBAAA,WAAA,gBAAA,WAAA,WAAA,WACA,QAAA,EASF,8CAAA,8CAEE,OAAA,KAQF,mBACE,mBAAA,YACA,gBAAA,YAAA,WAAA,YAAA,mBAAA,UASF,iDAAA,8CAEE,mBAAA,KAOF,SACE,QAAA,MAAA,OAAA,MACA,OAAA,EAAA,IACA,OAAA,IAAA,MAAA,OAQF,OACE,QAAA,EACA,OAAA,EAOF,SACE,SAAA,KAQF,SACE,YAAA,IAUF,MACE,eAAA,EACA,gBAAA,SAGF,GAAA,GAEE,QAAA,uFCjUF,aA7FI,EAAA,OAAA,QAGI,MAAA,eACA,YAAA,eACA,WAAA,cAAA,mBAAA,eACA,WAAA,eAGJ,EAAA,UAEI,gBAAA,UAGJ,cACI,QAAA,KAAA,WAAA,IAGJ,kBACI,QAAA,KAAA,YAAA,IAKJ,6BAAA,mBAEI,QAAA,GAGJ,WAAA,IAEI,OAAA,IAAA,MAAA,KC4KL,kBAAA,MDvKK,MC0KL,QAAA,mBDrKK,IE8KN,GDLC,kBAAA,MDrKK,ICwKL,UAAA,eCUD,GF5KM,GE2KN,EF1KM,QAAA,ECuKL,OAAA,ECSD,GF3KM,GCsKL,iBAAA,MD/JK,QCkKL,QAAA,KCSD,YFtKU,oBCiKT,iBAAA,eD7JK,OCgKL,OAAA,IAAA,MAAA,KD5JK,OC+JL,gBAAA,mBCSD,UFpKU,UC+JT,iBAAA,eDzJS,mBEkKV,mBDLC,OAAA,IAAA,MAAA,gBEjPD,WACA,YAAA,uBFsPD,IAAA,+CE7OC,IAAK,sDAAuD,4BAA6B,iDAAkD,gBAAiB,gDAAiD,eAAgB,+CAAgD,mBAAoB,2EAA4E,cAE7W,WACA,SAAA,SACA,IAAA,IACA,QAAA,aACA,YAAA,uBACA,WAAA,OACA,YAAA,IACA,YAAA,EAIkC,uBAAA,YAAW,wBAAA,UACX,2BAAW,QAAA,QAEX,uBDuPlC,QAAS,QCtPyB,sBFiPnC,uBEjP8C,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,qBAAW,QAAA,QACX,0BAAW,QAAA,QACX,qBAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,2BAAW,QAAA,QACX,sBAAW,QAAA,QACX,yBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,+BAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,8BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,yBAAW,QAAA,QACX,8BAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,gCAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,iCAAW,QAAA,QACX,0BAAW,QAAA,QACX,6BAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,gCAAW,QAAA,QACX,gCAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,0BAAW,QAAA,QACX,+BAAW,QAAA,QACX,+BAAW,QAAA,QACX,wBAAW,QAAA,QACX,+BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,0BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,gCAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,6BAAW,QAAA,QACX,8BAAW,QAAA,QACX,2BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,mCAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,+BAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,yBAAW,QAAA,QACX,0BAAW,QAAA,QACX,yBAAW,QAAA,QACX,6BAAW,QAAA,QACX,+BAAW,QAAA,QACX,0BAAW,QAAA,QACX,gCAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,kCAAW,QAAA,QACX,oCAAW,QAAA,QACX,sBAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,0BAAW,QAAA,QACX,4BAAW,QAAA,QACX,qCAAW,QAAA,QACX,oCAAW,QAAA,QACX,kCAAW,QAAA,QACX,oCAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,8BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,uBAAW,QAAA,QACX,mCAAW,QAAA,QACX,uCAAW,QAAA,QACX,gCAAW,QAAA,QACX,oCAAW,QAAA,QACX,qCAAW,QAAA,QACX,yCAAW,QAAA,QACX,4BAAW,QAAA,QACX,yBAAW,QAAA,QACX,gCAAW,QAAA,QACX,8BAAW,QAAA,QACX,yBAAW,QAAA,QACX,wBAAW,QAAA,QACX,0BAAW,QAAA,QACX,6BAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,yBAAW,QAAA,QACX,yBAAW,QAAA,QACX,uBAAW,QAAA,QACX,8BAAW,QAAA,QACX,+BAAW,QAAA,QACX,gCAAW,QAAA,QACX,8BAAW,QAAA,QACX,8BAAW,QAAA,QACX,8BAAW,QAAA,QACX,2BAAW,QAAA,QACX,0BAAW,QAAA,QACX,yBAAW,QAAA,QACX,6BAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,2BAAW,QAAA,QACX,2BAAW,QAAA,QACX,4BAAW,QAAA,QACX,+BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,iCAAW,QAAA,QACX,oCAAW,QAAA,QACX,iCAAW,QAAA,QACX,+BAAW,QAAA,QACX,+BAAW,QAAA,QACX,iCAAW,QAAA,QACX,qBAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,2BAAW,QAAA,QACX,uBAAW,QAAA,QASX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,uBAAW,QAAA,QACX,wBAAW,QAAA,QACX,uBAAW,QAAA,QACX,yBAAW,QAAA,QACX,yBAAW,QAAA,QACX,+BAAW,QAAA,QACX,uBAAW,QAAA,QACX,6BAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,uBAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,2BAAW,QAAA,QACX,0BAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,4BAAW,QAAA,QACX,mCAAW,QAAA,QACX,4BAAW,QAAA,QACX,oCAAW,QAAA,QACX,kCAAW,QAAA,QACX,iCAAW,QAAA,QACX,+BAAW,QAAA,QACX,sBAAW,QAAA,QACX,wBAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,kCAAW,QAAA,QACX,mCAAW,QAAA,QACX,sCAAW,QAAA,QACX,0CAAW,QAAA,QACX,oCAAW,QAAA,QACX,wCAAW,QAAA,QACX,qCAAW,QAAA,QACX,iCAAW,QAAA,QACX,gCAAW,QAAA,QACX,kCAAW,QAAA,QACX,+BAAW,QAAA,QACX,0BAAW,QAAA,QACX,8BAAW,QAAA,QACX,4BAAW,QAAA,QACX,4BAAW,QAAA,QACX,6BAAW,QAAA,QACX,4BAAW,QAAA,QCtS/C,0BCgEE,QAAA,QHi+BF,EDNC,mBAAA,WGxhCI,gBAAiB,WFiiCZ,WAAY,WGl+BZ,OADL,QJg+BJ,mBAAA,WGthCI,gBAAiB,WACpB,WAAA,WHyhCD,KGrhCC,UAAW,KAEX,4BAAA,cAEA,KACA,YAAA,iBAAA,UAAA,MAAA,WHuhCD,UAAA,KGnhCC,YAAa,WF4hCb,MAAO,KACP,iBAAkB,KExhClB,OADA,MAEA,OHqhCD,SG/gCC,YAAa,QACb,UAAA,QACA,YAAA,QAEA,EFwhCA,MAAO,QEthCL,gBAAA,KAIF,QH8gCD,QKjkCC,MAAA,QACA,gBAAA,UF6DF,QACE,QAAA,IAAA,KAAA,yBHygCD,eAAA,KGlgCC,OHqgCD,OAAA,ECSD,IACE,eAAgB,ODDjB,4BM/kCC,0BLklCF,gBKnlCE,iBADA,eH4EA,QAAS,MACT,UAAA,KHugCD,OAAA,KGhgCC,aACA,cAAA,IAEA,eACA,QAAA,aC6FA,UAAA,KACK,OAAA,KACG,QAAA,IEvLR,YAAA,WACA,iBAAA,KACA,OAAA,IAAA,MAAA,KN+lCD,cAAA,IGjgCC,mBAAoB,IAAI,IAAI,YAC5B,cAAA,IAAA,IAAA,YHmgCD,WAAA,IAAA,IAAA,YG5/BC,YACA,cAAA,IAEA,GH+/BD,WAAA,KGv/BC,cAAe,KACf,OAAA,EACA,WAAA,IAAA,MAAA,KAEA,SACA,SAAA,SACA,MAAA,IACA,OAAA,IACA,QAAA,EHy/BD,OAAA,KGj/BC,SAAA,OF0/BA,KAAM,cEx/BJ,OAAA,EAEA,0BACA,yBACA,SAAA,OACA,MAAA,KHm/BH,OAAA,KGx+BC,OAAQ,EACR,SAAA,QH0+BD,KAAA,KCSD,cACE,OAAQ,QAQV,IACA,IMlpCE,IACA,IACA,IACA,INwoCF,GACA,GACA,GACA,GACA,GACA,GDAC,YAAA,QOlpCC,YAAa,IN2pCb,YAAa,IACb,MAAO,QAoBT,WAZA,UAaA,WAZA,UM5pCI,WN6pCJ,UM5pCI,WN6pCJ,UM5pCI,WN6pCJ,UDMC,WCLD,UACA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SAaA,UAZA,SMppCE,YAAa,INwqCb,YAAa,EACb,MAAO,KAGT,IMxqCE,IAJF,IN2qCA,GAEA,GDLC,GCSC,WAAY,KACZ,cAAe,KASjB,WANA,UDCC,WCCD,UM5qCA,WN8qCA,UACA,UANA,SM5qCI,UN8qCJ,SM3qCA,UN6qCA,SAQE,UAAW,IAGb,IMprCE,IAJF,INurCA,GAEA,GDLC,GCSC,WAAY,KACZ,cAAe,KASjB,WANA,UDCC,WCCD,UMvrCA,WNyrCA,UACA,UANA,SMxrCI,UN0rCJ,SMtrCA,UNwrCA,SMxrCU,UAAA,IACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KACV,IAAA,GAAU,UAAA,KAOR,IADF,GPssCC,UAAA,KCSD,EMzsCE,OAAA,EAAA,EAAA,KAEA,MPosCD,cAAA,KO/rCC,UAAW,KAwOX,YAAa,IA1OX,YAAA,IPssCH,yBO7rCC,MNssCE,UAAW,MMjsCf,OAAA,MAEE,UAAA,IAKF,MP0rCC,KO1rCsB,QAAA,KP6rCtB,iBAAA,QO5rCsB,WP+rCtB,WAAA,KO9rCsB,YPisCtB,WAAA,MOhsCsB,aPmsCtB,WAAA,OOlsCsB,cPqsCtB,WAAA,QOlsCsB,aPqsCtB,YAAA,OOpsCsB,gBPusCtB,eAAA,UOtsCsB,gBPysCtB,eAAA,UOrsCC,iBPwsCD,eAAA,WQ3yCC,YR8yCD,MAAA,KCSD,cOpzCI,MAAA,QAHF,qBDwGF,qBP6sCC,MAAA,QCSD,cO3zCI,MAAA,QAHF,qBD2GF,qBPitCC,MAAA,QCSD,WOl0CI,MAAA,QAHF,kBD8GF,kBPqtCC,MAAA,QCSD,cOz0CI,MAAA,QAHF,qBDiHF,qBPytCC,MAAA,QCSD,aOh1CI,MAAA,QDwHF,oBAHF,oBExHE,MAAA,QACA,YR01CA,MAAO,KQx1CL,iBAAA,QAHF,mBF8HF,mBP2tCC,iBAAA,QCSD,YQ/1CI,iBAAA,QAHF,mBFiIF,mBP+tCC,iBAAA,QCSD,SQt2CI,iBAAA,QAHF,gBFoIF,gBPmuCC,iBAAA,QCSD,YQ72CI,iBAAA,QAHF,mBFuIF,mBPuuCC,iBAAA,QCSD,WQp3CI,iBAAA,QF6IF,kBADF,kBAEE,iBAAA,QPsuCD,aO7tCC,eAAgB,INsuChB,OAAQ,KAAK,EAAE,KMpuCf,cAAA,IAAA,MAAA,KAFF,GPkuCC,GCSC,WAAY,EACZ,cAAe,KM9tCf,MP0tCD,MO3tCD,MAPI,MASF,cAAA,EAIF,eALE,aAAA,EACA,WAAA,KPkuCD,aO9tCC,aAAc,EAKZ,YAAA,KACA,WAAA,KP6tCH,gBOvtCC,QAAS,aACT,cAAA,IACA,aAAA,IAEF,GNguCE,WAAY,EM9tCZ,cAAA,KAGA,GADF,GP0tCC,YAAA,WOttCC,GPytCD,YAAA,IOnnCD,GAvFM,YAAA,EAEA,yBACA,kBGtNJ,MAAA,KACA,MAAA,MACA,SAAA,OVq6CC,MAAA,KO7nCC,WAAY,MAhFV,cAAA,SPgtCH,YAAA,OOtsCD,kBNgtCE,YAAa,OM1sCjB,0BPssCC,YOrsCC,OAAA,KA9IqB,cAAA,IAAA,OAAA,KAmJvB,YACE,UAAA,IACA,eAAA,UAEA,WPssCD,QAAA,KAAA,KOjsCG,OAAA,EAAA,EAAA,KN0sCF,UAAW,OACX,YAAa,IAAI,MAAM,KMptCzB,yBP+sCC,wBO/sCD,yBNytCE,cAAe,EMnsCb,kBAFA,kBACA,iBPksCH,QAAA,MO/rCG,UAAA,INwsCF,YAAa,WACb,MAAO,KMhsCT,yBP2rCC,yBO3rCD,wBAEE,QAAA,cAEA,oBACA,sBACA,cAAA,KP6rCD,aAAA,EOvrCG,WAAA,MNgsCF,aAAc,IAAI,MAAM,KACxB,YAAa,EMhsCX,kCNksCJ,kCMnsCe,iCACX,oCNmsCJ,oCDLC,mCCUC,QAAS,GMjsCX,iCNmsCA,iCMzsCM,gCAOJ,mCNmsCF,mCDLC,kCO7rCC,QAAA,cPksCD,QWv+CC,cAAe,KVg/Cf,WAAY,OACZ,YAAa,WU7+Cb,KXy+CD,IWr+CD,IACE,KACA,YAAA,MAAA,OAAA,SAAA,cAAA,UAEA,KACA,QAAA,IAAA,IXu+CD,UAAA,IWn+CC,MAAO,QACP,iBAAA,QACA,cAAA,IAEA,IACA,QAAA,IAAA,IACA,UAAA,IV4+CA,MU5+CA,KXq+CD,iBAAA,KW3+CC,cAAe,IASb,mBAAA,MAAA,EAAA,KAAA,EAAA,gBACA,WAAA,MAAA,EAAA,KAAA,EAAA,gBAEA,QV6+CF,QU7+CE,EXq+CH,UAAA,KWh+CC,YAAa,IACb,mBAAA,KACA,WAAA,KAEA,IACA,QAAA,MACA,QAAA,MACA,OAAA,EAAA,EAAA,KACA,UAAA,KACA,YAAA,WACA,MAAA,KACA,WAAA,UXk+CD,UAAA,WW7+CC,iBAAkB,QAehB,OAAA,IAAA,MAAA,KACA,cAAA,IAEA,SACA,QAAA,EACA,UAAA,QXi+CH,MAAA,QW59CC,YAAa,SACb,iBAAA,YACA,cAAA,EC1DF,gBCHE,WAAA,MACA,WAAA,OAEA,Wb8hDD,cAAA,KYxhDC,aAAA,KAqEA,aAAc,KAvEZ,YAAA,KZ+hDH,yBY1hDC,WAkEE,MAAO,OZ69CV,yBY5hDC,WA+DE,MAAO,OZk+CV,0BYzhDC,WCvBA,MAAA,QAGA,iBbmjDD,cAAA,KYthDC,aAAc,KCvBd,aAAA,KACA,YAAA,KCAE,KACE,aAAA,MAEA,YAAA,MAGA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UdgjDL,SAAA,SchiDG,WAAA,IACE,cAAA,KdkiDL,aAAA,Kc1hDG,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Ud6hDH,MAAA,Kc7hDG,WdgiDH,MAAA,KchiDG,WdmiDH,MAAA,acniDG,WdsiDH,MAAA,actiDG,UdyiDH,MAAA,IcziDG,Ud4iDH,MAAA,ac5iDG,Ud+iDH,MAAA,ac/iDG,UdkjDH,MAAA,IcljDG,UdqjDH,MAAA,acrjDG,UdwjDH,MAAA,acxjDG,Ud2jDH,MAAA,Ic3jDG,Ud8jDH,MAAA,ac/iDG,UdkjDH,MAAA,YcljDG,gBdqjDH,MAAA,KcrjDG,gBdwjDH,MAAA,acxjDG,gBd2jDH,MAAA,ac3jDG,ed8jDH,MAAA,Ic9jDG,edikDH,MAAA,acjkDG,edokDH,MAAA,acpkDG,edukDH,MAAA,IcvkDG,ed0kDH,MAAA,ac1kDG,ed6kDH,MAAA,ac7kDG,edglDH,MAAA,IchlDG,edmlDH,MAAA,ac9kDG,edilDH,MAAA,YchmDG,edmmDH,MAAA,KcnmDG,gBdsmDH,KAAA,KctmDG,gBdymDH,KAAA,aczmDG,gBd4mDH,KAAA,ac5mDG,ed+mDH,KAAA,Ic/mDG,edknDH,KAAA,aclnDG,edqnDH,KAAA,acrnDG,edwnDH,KAAA,IcxnDG,ed2nDH,KAAA,ac3nDG,ed8nDH,KAAA,ac9nDG,edioDH,KAAA,IcjoDG,edooDH,KAAA,ac/nDG,edkoDH,KAAA,YcnnDG,edsnDH,KAAA,KctnDG,kBdynDH,YAAA,KcznDG,kBd4nDH,YAAA,ac5nDG,kBd+nDH,YAAA,ac/nDG,iBdkoDH,YAAA,IcloDG,iBdqoDH,YAAA,acroDG,iBdwoDH,YAAA,acxoDG,iBd2oDH,YAAA,Ic3oDG,iBd8oDH,YAAA,ac9oDG,iBdipDH,YAAA,acjpDG,iBdopDH,YAAA,IcppDG,iBdupDH,YAAA,acvpDG,iBd0pDH,YAAA,Yc5rDG,iBACE,YAAA,EAOJ,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Ud0rDD,MAAA,Kc1rDC,Wd6rDD,MAAA,Kc7rDC,WdgsDD,MAAA,achsDC,WdmsDD,MAAA,acnsDC,UdssDD,MAAA,IctsDC,UdysDD,MAAA,aczsDC,Ud4sDD,MAAA,ac5sDC,Ud+sDD,MAAA,Ic/sDC,UdktDD,MAAA,acltDC,UdqtDD,MAAA,acrtDC,UdwtDD,MAAA,IcxtDC,Ud2tDD,MAAA,ac5sDC,Ud+sDD,MAAA,Yc/sDC,gBdktDD,MAAA,KcltDC,gBdqtDD,MAAA,acrtDC,gBdwtDD,MAAA,acxtDC,ed2tDD,MAAA,Ic3tDC,ed8tDD,MAAA,ac9tDC,ediuDD,MAAA,acjuDC,edouDD,MAAA,IcpuDC,eduuDD,MAAA,acvuDC,ed0uDD,MAAA,ac1uDC,ed6uDD,MAAA,Ic7uDC,edgvDD,MAAA,ac3uDC,ed8uDD,MAAA,Yc7vDC,edgwDD,MAAA,KchwDC,gBdmwDD,KAAA,KcnwDC,gBdswDD,KAAA,actwDC,gBdywDD,KAAA,aczwDC,ed4wDD,KAAA,Ic5wDC,ed+wDD,KAAA,ac/wDC,edkxDD,KAAA,aclxDC,edqxDD,KAAA,IcrxDC,edwxDD,KAAA,acxxDC,ed2xDD,KAAA,ac3xDC,ed8xDD,KAAA,Ic9xDC,ediyDD,KAAA,ac5xDC,ed+xDD,KAAA,YchxDC,edmxDD,KAAA,KcnxDC,kBdsxDD,YAAA,KctxDC,kBdyxDD,YAAA,aczxDC,kBd4xDD,YAAA,ac5xDC,iBd+xDD,YAAA,Ic/xDC,iBdkyDD,YAAA,aclyDC,iBdqyDD,YAAA,acryDC,iBdwyDD,YAAA,IcxyDC,iBd2yDD,YAAA,ac3yDC,iBd8yDD,YAAA,ac9yDC,iBdizDD,YAAA,IcjzDC,iBdozDD,YAAA,acpzDC,iBduzDD,YAAA,YY9yDD,iBE3CE,YAAA,GAQF,yBACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Udw1DD,MAAA,Kcx1DC,Wd21DD,MAAA,Kc31DC,Wd81DD,MAAA,ac91DC,Wdi2DD,MAAA,acj2DC,Udo2DD,MAAA,Icp2DC,Udu2DD,MAAA,acv2DC,Ud02DD,MAAA,ac12DC,Ud62DD,MAAA,Ic72DC,Udg3DD,MAAA,ach3DC,Udm3DD,MAAA,acn3DC,Uds3DD,MAAA,Ict3DC,Udy3DD,MAAA,ac12DC,Ud62DD,MAAA,Yc72DC,gBdg3DD,MAAA,Kch3DC,gBdm3DD,MAAA,acn3DC,gBds3DD,MAAA,act3DC,edy3DD,MAAA,Icz3DC,ed43DD,MAAA,ac53DC,ed+3DD,MAAA,ac/3DC,edk4DD,MAAA,Icl4DC,edq4DD,MAAA,acr4DC,edw4DD,MAAA,acx4DC,ed24DD,MAAA,Ic34DC,ed84DD,MAAA,acz4DC,ed44DD,MAAA,Yc35DC,ed85DD,MAAA,Kc95DC,gBdi6DD,KAAA,Kcj6DC,gBdo6DD,KAAA,acp6DC,gBdu6DD,KAAA,acv6DC,ed06DD,KAAA,Ic16DC,ed66DD,KAAA,ac76DC,edg7DD,KAAA,ach7DC,edm7DD,KAAA,Icn7DC,eds7DD,KAAA,act7DC,edy7DD,KAAA,acz7DC,ed47DD,KAAA,Ic57DC,ed+7DD,KAAA,ac17DC,ed67DD,KAAA,Yc96DC,edi7DD,KAAA,Kcj7DC,kBdo7DD,YAAA,Kcp7DC,kBdu7DD,YAAA,acv7DC,kBd07DD,YAAA,ac17DC,iBd67DD,YAAA,Ic77DC,iBdg8DD,YAAA,ach8DC,iBdm8DD,YAAA,acn8DC,iBds8DD,YAAA,Ict8DC,iBdy8DD,YAAA,acz8DC,iBd48DD,YAAA,ac58DC,iBd+8DD,YAAA,Ic/8DC,iBdk9DD,YAAA,acl9DC,iBdq9DD,YAAA,YYz8DD,iBE9CE,YAAA,GAQF,0BACE,UAAA,WAAA,WAAA,WAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,UAAA,Uds/DD,MAAA,Kct/DC,Wdy/DD,MAAA,Kcz/DC,Wd4/DD,MAAA,ac5/DC,Wd+/DD,MAAA,ac//DC,UdkgED,MAAA,IclgEC,UdqgED,MAAA,acrgEC,UdwgED,MAAA,acxgEC,Ud2gED,MAAA,Ic3gEC,Ud8gED,MAAA,ac9gEC,UdihED,MAAA,acjhEC,UdohED,MAAA,IcphEC,UduhED,MAAA,acxgEC,Ud2gED,MAAA,Yc3gEC,gBd8gED,MAAA,Kc9gEC,gBdihED,MAAA,acjhEC,gBdohED,MAAA,acphEC,eduhED,MAAA,IcvhEC,ed0hED,MAAA,ac1hEC,ed6hED,MAAA,ac7hEC,edgiED,MAAA,IchiEC,edmiED,MAAA,acniEC,edsiED,MAAA,actiEC,edyiED,MAAA,IcziEC,ed4iED,MAAA,acviEC,ed0iED,MAAA,YczjEC,ed4jED,MAAA,Kc5jEC,gBd+jED,KAAA,Kc/jEC,gBdkkED,KAAA,aclkEC,gBdqkED,KAAA,acrkEC,edwkED,KAAA,IcxkEC,ed2kED,KAAA,ac3kEC,ed8kED,KAAA,ac9kEC,edilED,KAAA,IcjlEC,edolED,KAAA,acplEC,edulED,KAAA,acvlEC,ed0lED,KAAA,Ic1lEC,ed6lED,KAAA,acxlEC,ed2lED,KAAA,Yc5kEC,ed+kED,KAAA,Kc/kEC,kBdklED,YAAA,KcllEC,kBdqlED,YAAA,acrlEC,kBdwlED,YAAA,acxlEC,iBd2lED,YAAA,Ic3lEC,iBd8lED,YAAA,ac9lEC,iBdimED,YAAA,acjmEC,iBdomED,YAAA,IcpmEC,iBdumED,YAAA,acvmEC,iBd0mED,YAAA,ac1mEC,iBd6mED,YAAA,Ic7mEC,iBdgnED,YAAA,achnEC,iBdmnED,YAAA,YetrED,iBACA,YAAA,GAGA,MACA,iBAAA,YAEA,QfyrED,YAAA,IevrEC,eAAgB,IAChB,MAAA,KfyrED,WAAA,KelrEC,GACA,WAAA,KfsrED,OexrEC,MAAO,KdmsEP,UAAW,KACX,cAAe,KcvrET,mBd0rER,mBczrEQ,mBAHA,mBACA,mBd0rER,mBDHC,QAAA,IensEC,YAAa,WAoBX,eAAA,IACA,WAAA,IAAA,MAAA,KArBJ,mBdktEE,eAAgB,OAChB,cAAe,IAAI,MAAM,KDJ1B,uCCMD,uCcrtEA,wCdstEA,wCclrEI,2CANI,2CforEP,WAAA,EezqEG,mBf4qEH,WAAA,IAAA,MAAA,KCWD,cACE,iBAAkB,Kc/pEpB,6BdkqEA,6BcjqEE,6BAZM,6BfsqEP,6BCMD,6BDHC,QAAA,ICWD,gBACE,OAAQ,IAAI,MAAM,Kc1qEpB,4Bd6qEA,4Bc7qEA,4BAQQ,4Bf8pEP,4BCMD,4Bc7pEM,OAAA,IAAA,MAAA,KAYF,4BAFJ,4BfopEC,oBAAA,IevoEG,yCf0oEH,iBAAA,QehoEC,4BACA,iBAAA,QfooED,uBe9nEG,SAAA,OdyoEF,QAAS,acxoEL,MAAA,KAEA,sBfioEL,sBgB7wEC,SAAA,OfwxEA,QAAS,WACT,MAAO,KAST,0BerxEE,0Bf+wEF,0BAGA,0BexxEM,0BAMJ,0BfgxEF,0BAGA,0BACA,0BDNC,0BCAD,0BAGA,0BASE,iBAAkB,QDLnB,sCgBlyEC,sCAAA,oCfyyEF,sCetxEM,sCf2xEJ,iBAAkB,QASpB,2Be1yEE,2BfoyEF,2BAGA,2Be7yEM,2BAMJ,2BfqyEF,2BAGA,2BACA,2BDNC,2BCAD,2BAGA,2BASE,iBAAkB,QDLnB,uCgBvzEC,uCAAA,qCf8zEF,uCe3yEM,uCfgzEJ,iBAAkB,QASpB,wBe/zEE,wBfyzEF,wBAGA,wBel0EM,wBAMJ,wBf0zEF,wBAGA,wBACA,wBDNC,wBCAD,wBAGA,wBASE,iBAAkB,QDLnB,oCgB50EC,oCAAA,kCfm1EF,oCeh0EM,oCfq0EJ,iBAAkB,QASpB,2Bep1EE,2Bf80EF,2BAGA,2Bev1EM,2BAMJ,2Bf+0EF,2BAGA,2BACA,2BDNC,2BCAD,2BAGA,2BASE,iBAAkB,QDLnB,uCgBj2EC,uCAAA,qCfw2EF,uCer1EM,uCf01EJ,iBAAkB,QASpB,0Bez2EE,0Bfm2EF,0BAGA,0Be52EM,0BAMJ,0Bfo2EF,0BAGA,0BACA,0BDNC,0BCAD,0BAGA,0BASE,iBAAkB,QDLnB,sCehtEC,sCADF,oCdwtEA,sCe12EM,sCDoJJ,iBAAA,QA6DF,kBACE,WAAY,KA3DV,WAAA,KAEA,oCACA,kBACA,MAAA,KfotED,cAAA,Ke7pEC,WAAY,OAnDV,mBAAA,yBfmtEH,OAAA,IAAA,MAAA,KCWD,yBACE,cAAe,Ec5qEjB,qCd+qEA,qCcjtEI,qCARM,qCfktET,qCCMD,qCDHC,YAAA,OCWD,kCACE,OAAQ,EcvrEV,0Dd0rEA,0Dc1rEA,0DAzBU,0Df4sET,0DCMD,0DAME,YAAa,Ec/rEf,yDdksEA,yDclsEA,yDArBU,yDfgtET,yDCMD,yDAME,aAAc,EDLjB,yDe1sEW,yDEzNV,yDjBk6EC,yDiBj6ED,cAAA,GAMA,SjBk6ED,UAAA,EiB/5EC,QAAS,EACT,OAAA,EACA,OAAA,EAEA,OACA,QAAA,MACA,MAAA,KACA,QAAA,EACA,cAAA,KACA,UAAA,KjBi6ED,YAAA,QiB95EC,MAAO,KACP,OAAA,EACA,cAAA,IAAA,MAAA,QAEA,MjBg6ED,QAAA,aiBr5EC,UAAW,Kb4BX,cAAA,IACG,YAAA,IJ63EJ,mBiBr5EC,mBAAoB,WhBg6EjB,gBAAiB,WgB95EpB,WAAA,WjBy5ED,qBiBv5EC,kBAGA,OAAQ,IAAI,EAAE,EACd,WAAA,MjBs5ED,YAAA,OiBj5EC,iBACA,QAAA,MAIF,kBhB25EE,QAAS,MgBz5ET,MAAA,KAIF,iBAAA,ahB05EE,OAAQ,KI99ER,uBY2EF,2BjB64EC,wBiB54EC,QAAA,IAAA,KAAA,yBACA,eAAA,KAEA,OACA,QAAA,MjB+4ED,YAAA,IiBr3EC,UAAW,KACX,YAAA,WACA,MAAA,KAEA,cACA,QAAA,MACA,MAAA,KACA,OAAA,KACA,QAAA,IAAA,KACA,UAAA,KACA,YAAA,WACA,MAAA,KbxDA,iBAAA,KACQ,iBAAA,KAyHR,OAAA,IAAA,MAAA,KACK,cAAA,IACG,mBAAA,MAAA,EAAA,IAAA,IAAA,iBJwzET,WAAA,MAAA,EAAA,IAAA,IAAA,iBkBh8EC,mBAAA,aAAA,YAAA,KAAA,mBAAA,YAAA,KACE,cAAA,aAAA,YAAA,KAAA,WAAA,YAAA,KACA,WAAA,aAAA,YAAA,KAAA,WAAA,YAAA,KdWM,oBJy7ET,aAAA,QIx5EC,QAAA,EACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,qBACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,qBAEF,gCAA0B,MAAA,KJ25E3B,QAAA,EI15EiC,oCJ65EjC,MAAA,KiBh4EG,yCACA,MAAA,KAQF,0BhBs4EA,iBAAkB,YAClB,OAAQ,EgBn4EN,wBjB63EH,wBiB13EC,iChBq4EA,iBAAkB,KgBn4EhB,QAAA,EAIF,wBACE,iCjB03EH,OAAA,YiB72EC,sBjBg3ED,OAAA,KiB91EG,mBhB02EF,mBAAoB,KAEtB,qDgB32EM,8BjBo2EH,8BiBj2EC,wCAAA,+BhB62EA,YAAa,KgB32EX,iCjBy2EH,iCiBt2EC,2CAAA,kChB02EF,0BACA,0BACA,oCACA,2BAKE,YAAa,KgBh3EX,iCjB82EH,iCACF,2CiBp2EC,kChBu2EA,0BACA,0BACA,oCACA,2BgBz2EA,YAAA,MhBi3EF,YgBv2EE,cAAA,KAGA,UADA,OjBi2ED,SAAA,SiBr2EC,QAAS,MhBg3ET,WAAY,KgBx2EV,cAAA,KAGA,gBADA,aAEA,WAAA,KjBi2EH,aAAA,KiB91EC,cAAe,EhBy2Ef,YAAa,IACb,OAAQ,QgBp2ER,+BjBg2ED,sCiBl2EC,yBACA,gCAIA,SAAU,ShBw2EV,WAAY,MgBt2EZ,YAAA,MAIF,oBAAA,cAEE,WAAA,KAGA,iBADA,cAEA,SAAA,SACA,QAAA,aACA,aAAA,KjB61ED,cAAA,EiB31EC,YAAa,IhBs2Eb,eAAgB,OgBp2EhB,OAAA,QAUA,kCjBo1ED,4BCWC,WAAY,EACZ,YAAa,KgBv1Eb,wCAAA,qCjBm1ED,8BCOD,+BgBh2EI,2BhB+1EJ,4BAME,OAAQ,YDNT,0BiBv1EG,uBAMF,oCAAA,iChB61EA,OAAQ,YDNT,yBiBp1EK,sBAaJ,mCAFF,gCAGE,OAAA,YAGA,qBjBy0ED,WAAA,KiBv0EC,YAAA,IhBk1EA,eAAgB,IgBh1Ed,cAAA,EjB00EH,8BiB5zED,8BCnQE,cAAA,EACA,aAAA,EAEA,UACA,OAAA,KlBkkFD,QAAA,IAAA,KkBhkFC,UAAA,KACE,YAAA,IACA,cAAA,IAGF,gBjB0kFA,OAAQ,KiBxkFN,YAAA,KD2PA,0BAFJ,kBAGI,OAAA,KAEA,6BACA,OAAA,KjBy0EH,QAAA,IAAA,KiB/0EC,UAAW,KAST,YAAA,IACA,cAAA,IAVJ,mChB81EE,OAAQ,KgBh1EN,YAAA,KAGA,6CAjBJ,qCAkBI,OAAA,KAEA,oCACA,OAAA,KjBy0EH,WAAA,KiBr0EC,QAAS,IAAI,KC/Rb,UAAA,KACA,YAAA,IAEA,UACA,OAAA,KlBumFD,QAAA,KAAA,KkBrmFC,UAAA,KACE,YAAA,UACA,cAAA,IAGF,gBjB+mFA,OAAQ,KiB7mFN,YAAA,KDuRA,0BAFJ,kBAGI,OAAA,KAEA,6BACA,OAAA,KjBk1EH,QAAA,KAAA,KiBx1EC,UAAW,KAST,YAAA,UACA,cAAA,IAVJ,mChBu2EE,OAAQ,KgBz1EN,YAAA,KAGA,6CAjBJ,qCAkBI,OAAA,KAEA,oCACA,OAAA,KjBk1EH,WAAA,KiBz0EC,QAAS,KAAK,KAEd,UAAA,KjB00ED,YAAA,UiBt0EG,cjBy0EH,SAAA,SiBp0EC,4BACA,cAAA,OAEA,uBACA,SAAA,SACA,IAAA,EACA,MAAA,EACA,QAAA,EACA,QAAA,MACA,MAAA,KjBu0ED,OAAA,KiBr0EC,YAAa,KhBg1Eb,WAAY,OACZ,eAAgB,KDLjB,oDiBv0EC,uCADA,iCAGA,MAAO,KhBg1EP,OAAQ,KACR,YAAa,KDLd,oDiBv0EC,uCADA,iCAKA,MAAO,KhB80EP,OAAQ,KACR,YAAa,KAKf,uBAEA,8BAJA,4BADA,yBAEA,oBAEA,2BDNC,4BkBruFG,mCAJA,yBD0ZJ,gCbvWE,MAAA,QJ2rFD,2BkBxuFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJgsFD,iCiBz1EC,aAAc,QC5YZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlByuFH,gCiB91EC,MAAO,QCtYL,iBAAA,QlBuuFH,aAAA,QCWD,oCACE,MAAO,QAKT,uBAEA,8BAJA,4BADA,yBAEA,oBAEA,2BDNC,4BkBnwFG,mCAJA,yBD6ZJ,gCb1WE,MAAA,QJytFD,2BkBtwFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJ8tFD,iCiBp3EC,aAAc,QC/YZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlBuwFH,gCiBz3EC,MAAO,QCzYL,iBAAA,QlBqwFH,aAAA,QCWD,oCACE,MAAO,QAKT,qBAEA,4BAJA,0BADA,uBAEA,kBAEA,yBDNC,0BkBjyFG,iCAJA,uBDgaJ,8Bb7WE,MAAA,QJuvFD,yBkBpyFG,aAAA,QACE,mBAAA,MAAA,EAAA,IAAA,IAAA,iBd4CJ,WAAA,MAAA,EAAA,IAAA,IAAA,iBJ4vFD,+BiB/4EC,aAAc,QClZZ,mBAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QACA,WAAA,MAAA,EAAA,IAAA,IAAA,iBAAA,EAAA,EAAA,IAAA,QlBqyFH,8BiBp5EC,MAAO,QC5YL,iBAAA,QlBmyFH,aAAA,QiB/4EG,kCjBk5EH,MAAA,QiB/4EG,2CjBk5EH,IAAA,KiBv4EC,mDACA,IAAA,EAEA,YjB04ED,QAAA,MiBvzEC,WAAY,IAwEZ,cAAe,KAtIX,MAAA,QAEA,yBjBy3EH,yBiBrvEC,QAAS,aA/HP,cAAA,EACA,eAAA,OjBw3EH,2BiB1vEC,QAAS,aAxHP,MAAA,KjBq3EH,eAAA,OiBj3EG,kCACA,QAAA,aAmHJ,0BhB4wEE,QAAS,aACT,eAAgB,OgBr3Ed,wCjB82EH,6CiBtwED,2CjBywEC,MAAA,KiB72EG,wCACA,MAAA,KAmGJ,4BhBwxEE,cAAe,EgBp3Eb,eAAA,OAGA,uBADA,oBjB82EH,QAAA,aiBpxEC,WAAY,EhB+xEZ,cAAe,EgBr3EX,eAAA,OAsFN,6BAAA,0BAjFI,aAAA,EAiFJ,4CjB6xEC,sCiBx2EG,SAAA,SjB22EH,YAAA,EiBh2ED,kDhB42EE,IAAK,GgBl2EL,2BjB+1EH,kCiBh2EG,wBAEA,+BAXF,YAAa,IhBo3Eb,WAAY,EgBn2EV,cAAA,EJviBF,2BIshBF,wBJrhBE,WAAA,KI4jBA,6BAyBA,aAAc,MAnCV,YAAA,MAEA,yBjBw1EH,gCACF,YAAA,IiBx3EG,cAAe,EAwCf,WAAA,OAwBJ,sDAdQ,MAAA,KjB80EL,yBACF,+CiBn0EC,YAAA,KAEE,UAAW,MjBs0EZ,yBACF,+CmBp6FG,YAAa,IACf,UAAA,MAGA,KACA,QAAA,aACA,QAAA,IAAA,KAAA,cAAA,EACA,UAAA,KACA,YAAA,IACA,YAAA,WACA,WAAA,OC0CA,YAAA,OACA,eAAA,OACA,iBAAA,aACA,aAAA,ahB+JA,OAAA,QACG,oBAAA,KACC,iBAAA,KACI,gBAAA,KJ+tFT,YAAA,KmBv6FG,iBAAA,KlBm7FF,OAAQ,IAAI,MAAM,YAClB,cAAe,IkB96Ff,kBdzBA,kBACA,WLk8FD,kBCOD,kBADA,WAME,QAAS,IAAI,KAAK,yBAClB,eAAgB,KkBh7FhB,WnBy6FD,WmB56FG,WlBw7FF,MAAO,KkBn7FL,gBAAA,Kf6BM,YADR,YJk5FD,iBAAA,KmBz6FC,QAAA,ElBq7FA,mBAAoB,MAAM,EAAE,IAAI,IAAI,iBAC5B,WAAY,MAAM,EAAE,IAAI,IAAI,iBoBh+FpC,cAGA,ejB8DA,wBACQ,OAAA,YJ05FT,OAAA,kBmBz6FG,mBAAA,KlBq7FM,WAAY,KkBn7FhB,QAAA,IASN,eC3DE,yBACA,eAAA,KpBi+FD,aoB99FC,MAAA,KnB0+FA,iBAAkB,KmBx+FhB,aAAA,KpBk+FH,mBoBh+FO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBi+FH,mBoB99FC,MAAA,KnB0+FA,iBAAkB,QAClB,aAAc,QmBt+FR,oBADJ,oBpBi+FH,mCoB99FG,MAAA,KnB0+FF,iBAAkB,QAClB,aAAc,QmBt+FN,0BnB4+FV,0BAHA,0BmB1+FM,0BnB4+FN,0BAHA,0BDFC,yCoBx+FK,yCnB4+FN,yCmBv+FE,MAAA,KnB++FA,iBAAkB,QAClB,aAAc,QmBx+FZ,oBpBg+FH,oBoBh+FG,mCnB6+FF,iBAAkB,KmBz+FV,4BnB8+FV,4BAHA,4BDHC,6BCOD,6BAHA,6BkB39FA,sCClBM,sCnB8+FN,sCmBx+FI,iBAAA,KACA,aAAA,KDcJ,oBC9DE,MAAA,KACA,iBAAA,KpB0hGD,aoBvhGC,MAAA,KnBmiGA,iBAAkB,QmBjiGhB,aAAA,QpB2hGH,mBoBzhGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB0hGH,mBoBvhGC,MAAA,KnBmiGA,iBAAkB,QAClB,aAAc,QmB/hGR,oBADJ,oBpB0hGH,mCoBvhGG,MAAA,KnBmiGF,iBAAkB,QAClB,aAAc,QmB/hGN,0BnBqiGV,0BAHA,0BmBniGM,0BnBqiGN,0BAHA,0BDFC,yCoBjiGK,yCnBqiGN,yCmBhiGE,MAAA,KnBwiGA,iBAAkB,QAClB,aAAc,QmBjiGZ,oBpByhGH,oBoBzhGG,mCnBsiGF,iBAAkB,KmBliGV,4BnBuiGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBjhGA,sCCrBM,sCnBuiGN,sCmBjiGI,iBAAA,QACA,aAAA,QDkBJ,oBClEE,MAAA,QACA,iBAAA,KpBmlGD,aoBhlGC,MAAA,KnB4lGA,iBAAkB,QmB1lGhB,aAAA,QpBolGH,mBoBllGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBmlGH,mBoBhlGC,MAAA,KnB4lGA,iBAAkB,QAClB,aAAc,QmBxlGR,oBADJ,oBpBmlGH,mCoBhlGG,MAAA,KnB4lGF,iBAAkB,QAClB,aAAc,QmBxlGN,0BnB8lGV,0BAHA,0BmB5lGM,0BnB8lGN,0BAHA,0BDFC,yCoB1lGK,yCnB8lGN,yCmBzlGE,MAAA,KnBimGA,iBAAkB,QAClB,aAAc,QmB1lGZ,oBpBklGH,oBoBllGG,mCnB+lGF,iBAAkB,KmB3lGV,4BnBgmGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBtkGA,sCCzBM,sCnBgmGN,sCmB1lGI,iBAAA,QACA,aAAA,QDsBJ,oBCtEE,MAAA,QACA,iBAAA,KpB4oGD,UoBzoGC,MAAA,KnBqpGA,iBAAkB,QmBnpGhB,aAAA,QpB6oGH,gBoB3oGO,gBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB4oGH,gBoBzoGC,MAAA,KnBqpGA,iBAAkB,QAClB,aAAc,QmBjpGR,iBADJ,iBpB4oGH,gCoBzoGG,MAAA,KnBqpGF,iBAAkB,QAClB,aAAc,QmBjpGN,uBnBupGV,uBAHA,uBmBrpGM,uBnBupGN,uBAHA,uBDFC,sCoBnpGK,sCnBupGN,sCmBlpGE,MAAA,KnB0pGA,iBAAkB,QAClB,aAAc,QmBnpGZ,iBpB2oGH,iBoB3oGG,gCnBwpGF,iBAAkB,KmBppGV,yBnBypGV,yBAHA,yBDHC,0BCOD,0BAHA,0BkB3nGA,mCC7BM,mCnBypGN,mCmBnpGI,iBAAA,QACA,aAAA,QD0BJ,iBC1EE,MAAA,QACA,iBAAA,KpBqsGD,aoBlsGC,MAAA,KnB8sGA,iBAAkB,QmB5sGhB,aAAA,QpBssGH,mBoBpsGO,mBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpBqsGH,mBoBlsGC,MAAA,KnB8sGA,iBAAkB,QAClB,aAAc,QmB1sGR,oBADJ,oBpBqsGH,mCoBlsGG,MAAA,KnB8sGF,iBAAkB,QAClB,aAAc,QmB1sGN,0BnBgtGV,0BAHA,0BmB9sGM,0BnBgtGN,0BAHA,0BDFC,yCoB5sGK,yCnBgtGN,yCmB3sGE,MAAA,KnBmtGA,iBAAkB,QAClB,aAAc,QmB5sGZ,oBpBosGH,oBoBpsGG,mCnBitGF,iBAAkB,KmB7sGV,4BnBktGV,4BAHA,4BDHC,6BCOD,6BAHA,6BkBhrGA,sCCjCM,sCnBktGN,sCmB5sGI,iBAAA,QACA,aAAA,QD8BJ,oBC9EE,MAAA,QACA,iBAAA,KpB8vGD,YoB3vGC,MAAA,KnBuwGA,iBAAkB,QmBrwGhB,aAAA,QpB+vGH,kBoB7vGO,kBAEN,MAAA,KACE,iBAAA,QACA,aAAA,QpB8vGH,kBoB3vGC,MAAA,KnBuwGA,iBAAkB,QAClB,aAAc,QmBnwGR,mBADJ,mBpB8vGH,kCoB3vGG,MAAA,KnBuwGF,iBAAkB,QAClB,aAAc,QmBnwGN,yBnBywGV,yBAHA,yBmBvwGM,yBnBywGN,yBAHA,yBDFC,wCoBrwGK,wCnBywGN,wCmBpwGE,MAAA,KnB4wGA,iBAAkB,QAClB,aAAc,QmBrwGZ,mBpB6vGH,mBoB7vGG,kCnB0wGF,iBAAkB,KmBtwGV,2BnB2wGV,2BAHA,2BDHC,4BCOD,4BAHA,4BkBruGA,qCCrCM,qCnB2wGN,qCmBrwGI,iBAAA,QACA,aAAA,QDuCJ,mBACE,MAAA,QACA,iBAAA,KnB+tGD,UmB5tGC,YAAA,IlBwuGA,MAAO,QACP,cAAe,EAEjB,UGzwGE,iBemCE,iBflCM,oBJkwGT,6BmB7tGC,iBAAA,YlByuGA,mBAAoB,KACZ,WAAY,KkBtuGlB,UAEF,iBAAA,gBnB6tGD,gBmB3tGG,aAAA,YnBiuGH,gBmB/tGG,gBAIA,MAAA,QlBuuGF,gBAAiB,UACjB,iBAAkB,YDNnB,0BmBhuGK,0BAUN,mCATM,mClB2uGJ,MAAO,KmB1yGP,gBAAA,KAGA,mBADA,QpBmyGD,QAAA,KAAA,KmBztGC,UAAW,KlBquGX,YAAa,UmBjzGb,cAAA,IAGA,mBADA,QpB0yGD,QAAA,IAAA,KmB5tGC,UAAW,KlBwuGX,YAAa,ImBxzGb,cAAA,IAGA,mBADA,QpBizGD,QAAA,IAAA,ImB3tGC,UAAW,KACX,YAAA,IACA,cAAA,IAIF,WACE,QAAA,MnB2tGD,MAAA,KCYD,sBACE,WAAY,IqBz3GZ,6BADF,4BtBk3GC,6BI7rGC,MAAA,KAEQ,MJisGT,QAAA,EsBr3GC,mBAAA,QAAA,KAAA,OACE,cAAA,QAAA,KAAA,OtBu3GH,WAAA,QAAA,KAAA,OsBl3GC,StBq3GD,QAAA,EsBn3Ga,UtBs3Gb,QAAA,KsBr3Ga,atBw3Gb,QAAA,MsBv3Ga,etB03Gb,QAAA,UsBt3GC,kBACA,QAAA,gBlBwKA,YACQ,SAAA,SAAA,OAAA,EAOR,SAAA,OACQ,mCAAA,KAAA,8BAAA,KAGR,2BAAA,KACQ,4BAAA,KAAA,uBAAA,KJ2sGT,oBAAA,KuBr5GC,4BAA6B,OAAQ,WACrC,uBAAA,OAAA,WACA,oBAAA,OAAA,WAEA,OACA,QAAA,aACA,MAAA,EACA,OAAA,EACA,YAAA,IACA,eAAA,OvBu5GD,WAAA,IAAA,OuBn5GC,WAAY,IAAI,QtBk6GhB,aAAc,IAAI,MAAM,YsBh6GxB,YAAA,IAAA,MAAA,YAKA,UADF,QvBo5GC,SAAA,SuB94GC,uBACA,QAAA,EAEA,eACA,SAAA,SACA,IAAA,KACA,KAAA,EACA,QAAA,KACA,QAAA,KACA,MAAA,KACA,UAAA,MACA,QAAA,IAAA,EACA,OAAA,IAAA,EAAA,EACA,UAAA,KACA,WAAA,KACA,WAAA,KnBsBA,iBAAA,KACQ,wBAAA,YmBrBR,gBAAA,YtB+5GA,OsB/5GA,IAAA,MAAA,KvBk5GD,OAAA,IAAA,MAAA,gBuB74GC,cAAA,IACE,mBAAA,EAAA,IAAA,KAAA,iBACA,WAAA,EAAA,IAAA,KAAA,iBAzBJ,0BCzBE,MAAA,EACA,KAAA,KAEA,wBxBo8GD,OAAA,IuB96GC,OAAQ,IAAI,EAmCV,SAAA,OACA,iBAAA,QAEA,oBACA,QAAA,MACA,QAAA,IAAA,KACA,MAAA,KvB84GH,YAAA,IuBx4GC,YAAA,WtBw5GA,MAAO,KsBt5GL,YAAA,OvB44GH,0BuB14GG,0BAMF,MAAA,QtBo5GA,gBAAiB,KACjB,iBAAkB,QsBj5GhB,yBAEA,+BADA,+BvBu4GH,MAAA,KuB73GC,gBAAA,KtB64GA,iBAAkB,QAClB,QAAS,EDZV,2BuB33GC,iCAAA,iCAEE,MAAA,KEzGF,iCF2GE,iCAEA,gBAAA,KvB63GH,OAAA,YuBx3GC,iBAAkB,YAGhB,iBAAA,KvBw3GH,OAAA,0DuBn3GG,qBvBs3GH,QAAA,MuB72GC,QACA,QAAA,EAQF,qBACE,MAAA,EACA,KAAA,KAIF,oBACE,MAAA,KACA,KAAA,EAEA,iBACA,QAAA,MACA,QAAA,IAAA,KvBw2GD,UAAA,KuBp2GC,YAAa,WACb,MAAA,KACA,YAAA,OAEA,mBACA,SAAA,MACA,IAAA,EvBs2GD,MAAA,EuBl2GC,OAAQ,EACR,KAAA,EACA,QAAA,IAQF,2BtB42GE,MAAO,EsBx2GL,KAAA,KAEA,eACA,sCvB41GH,QAAA,GuBn2GC,WAAY,EtBm3GZ,cAAe,IAAI,OsBx2GjB,cAAA,IAAA,QAEA,uBvB41GH,8CuBv0GC,IAAK,KAXL,OAAA,KApEA,cAAA,IvB25GC,yBuBv1GD,6BA1DA,MAAA,EACA,KAAA,KvBq5GD,kC0BpiHG,MAAO,KzBojHP,KAAM,GyBhjHR,W1BsiHD,oB0B1iHC,SAAU,SzB0jHV,QAAS,ayBpjHP,eAAA,OAGA,yB1BsiHH,gBCgBC,SAAU,SACV,MAAO,KyB7iHT,gC1BsiHC,gCCYD,+BAFA,+ByBhjHA,uBANM,uBzBujHN,sBAFA,sBAQE,QAAS,EyBljHP,qB1BuiHH,2B0BliHD,2BACE,iC1BoiHD,YAAA,KCgBD,aACE,YAAa,KDZd,kB0B1iHD,wBAAA,0BzB2jHE,MAAO,KDZR,kB0B/hHD,wBACE,0B1BiiHD,YAAA,I0B5hHC,yE1B+hHD,cAAA,E2BhlHC,4BACG,YAAA,EDsDL,mEzB6iHE,wBAAyB,E0B5lHzB,2BAAA,E3BilHD,6C0B5hHD,8CACE,uBAAA,E1B8hHD,0BAAA,E0B3hHC,sB1B8hHD,MAAA,KCgBD,8D0B/mHE,cAAA,E3BomHD,mE0B3hHD,oECjEE,wBAAA,EACG,2BAAA,EDqEL,oEzB0iHE,uBAAwB,EyBxiHxB,0BAAA,EAiBF,mCACE,iCACA,QAAA,EAEF,iCACE,cAAA,IACA,aAAA,IAKF,oCtB/CE,cAAA,KACQ,aAAA,KsBkDR,iCtBnDA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBsByDV,0CACE,mBAAA,K1BugHD,WAAA,K0BngHC,YACA,YAAA,EAGF,eACE,aAAA,IAAA,IAAA,E1BqgHD,oBAAA,ECgBD,uBACE,aAAc,EAAE,IAAI,IyB1gHlB,yBACA,+BACA,oC1B+/GH,QAAA,M0BtgHC,MAAO,KAcH,MAAA,K1B2/GL,UAAA,KCgBD,oCACE,MAAO,KyBpgHL,8BACA,oC1By/GH,oC0Bp/GC,0CACE,WAAA,K1Bs/GH,YAAA,E2B/pHC,4DACC,cAAA,EAQA,sD3B4pHF,uBAAA,I0Bt/GC,wBAAA,IC/KA,2BAAA,EACC,0BAAA,EAQA,sD3BkqHF,uBAAA,E0Bv/GC,wBAAyB,EACzB,2BAAA,I1By/GD,0BAAA,ICgBD,uE0BtrHE,cAAA,E3B2qHD,4E0Bt/GD,6EC7LE,2BAAA,EACC,0BAAA,EDoMH,6EACE,uBAAA,EACA,wBAAA,EAEA,qB1Bo/GD,QAAA,M0Bx/GC,MAAO,KzBwgHP,aAAc,MyBjgHZ,gBAAA,SAEA,0B1Bq/GH,gC0B9/GC,QAAS,WAYP,MAAA,K1Bq/GH,MAAA,G0Bj/GG,qC1Bo/GH,MAAA,KCgBD,+CACE,KAAM,KyB7+GF,gDAFA,6C1Bs+GL,2D0Br+GK,wDEzOJ,SAAU,SACV,KAAA,cACA,eAAA,K5BitHD,a4B7sHC,SAAA,SACE,QAAA,MACA,gBAAA,S5BgtHH,0B4BxtHC,MAAO,KAeL,cAAA,EACA,aAAA,EAOA,2BACA,SAAA,S5BusHH,QAAA,E4BrsHG,MAAA,KACE,MAAA,K5BusHL,cAAA,ECgBD,iCACE,QAAS,EiBnrHT,8BACA,mCACA,sCACA,OAAA,KlBwqHD,QAAA,KAAA,KkBtqHC,UAAA,KjBsrHA,YAAa,UACb,cAAe,IiBrrHb,oClB0qHH,yCkBvqHC,4CjBurHA,OAAQ,KACR,YAAa,KDTd,8C4B/sHD,mDAAA,sD3B0tHA,sCACA,2CiBzrHI,8CjB8rHF,OAAQ,KiB1sHR,8BACA,mCACA,sCACA,OAAA,KlB+rHD,QAAA,IAAA,KkB7rHC,UAAA,KjB6sHA,YAAa,IACb,cAAe,IiB5sHb,oClBisHH,yCkB9rHC,4CjB8sHA,OAAQ,KACR,YAAa,KDTd,8C4B7tHD,mDAAA,sD3BwuHA,sCACA,2CiBhtHI,8CjBqtHF,OAAQ,K2BzuHR,2B5B6tHD,mB4B7tHC,iB3B8uHA,QAAS,W2BzuHX,8D5B6tHC,sD4B7tHD,oDAEE,cAAA,EAEA,mB5B+tHD,iB4B1tHC,MAAO,GACP,YAAA,OACA,eAAA,OAEA,mBACA,QAAA,IAAA,KACA,UAAA,KACA,YAAA,IACA,YAAA,EACA,MAAA,K5B4tHD,WAAA,O4BztHC,iBAAA,KACE,OAAA,IAAA,MAAA,KACA,cAAA,I5B4tHH,4B4BztHC,QAAA,IAAA,KACE,UAAA,KACA,cAAA,I5B4tHH,4B4B/uHC,QAAS,KAAK,K3B+vHd,UAAW,K2BruHT,cAAA,IAKJ,wCAAA,qC3BquHE,WAAY,EAEd,uCACA,+BACA,kC0B70HE,6CACG,8CC4GL,6D5BqtHC,wE4BptHC,wBAAA,E5ButHD,2BAAA,ECgBD,+BACE,aAAc,EAEhB,sCACA,8B2BhuHA,+D5BstHC,oDCWD,iC0Bl1HE,4CACG,6CCiHH,uBAAA,E5BwtHD,0BAAA,E4BltHC,8BAGA,YAAA,E5BotHD,iB4BxtHC,SAAU,SAUR,UAAA,E5BitHH,YAAA,O4B/sHK,sB5BktHL,SAAA,SCgBD,2BACE,YAAa,K2BxtHb,6BAAA,4B5B4sHD,4B4BzsHK,QAAA,EAGJ,kCAAA,wCAGI,aAAA,K5B4sHL,iC6B12HD,uCACE,QAAA,EACA,YAAA,K7B62HD,K6B/2HC,aAAc,EAOZ,cAAA,EACA,WAAA,KARJ,QAWM,SAAA,SACA,QAAA,M7B42HL,U6B12HK,SAAA,S5B03HJ,QAAS,M4Bx3HH,QAAA,KAAA,KAMJ,gB7Bu2HH,gB6Bt2HK,gBAAA,K7By2HL,iBAAA,KCgBD,mB4Br3HQ,MAAA,KAGA,yBADA,yB7B02HP,MAAA,K6Bl2HG,gBAAA,K5Bk3HF,OAAQ,YACR,iBAAkB,Y4B/2Hd,aAzCN,mB7B64HC,mBwBh5HC,iBAAA,KACA,aAAA,QAEA,kBxBm5HD,OAAA,I6Bn5HC,OAAQ,IAAI,EA0DV,SAAA,O7B41HH,iBAAA,Q6Bl1HC,c7Bq1HD,UAAA,K6Bn1HG,UAEA,cAAA,IAAA,MAAA,KALJ,aASM,MAAA,KACA,cAAA,KAEA,e7Bo1HL,aAAA,I6Bn1HK,YAAA,WACE,OAAA,IAAA,MAAA,Y7Bq1HP,cAAA,IAAA,IAAA,EAAA,ECgBD,qBACE,aAAc,KAAK,KAAK,K4B51HlB,sBAEA,4BADA,4BAEA,MAAA,K7Bi1HP,OAAA,Q6B50HC,iBAAA,KAqDA,OAAA,IAAA,MAAA,KA8BA,oBAAA,YAnFA,wBAwDE,MAAA,K7B2xHH,cAAA,E6BzxHK,2BACA,MAAA,KA3DJ,6BAgEE,cAAA,IACA,WAAA,OAYJ,iDA0DE,IAAK,KAjED,KAAA,K7B0xHH,yB6BztHD,2BA9DM,QAAA,W7B0xHL,MAAA,G6Bn2HD,6BAuFE,cAAA,GAvFF,6B5Bw3HA,aAAc,EACd,cAAe,IDZhB,kC6BtuHD,wCA3BA,wCATM,OAAA,IAAA,MAAA,K7B+wHH,yB6B3uHD,6B5B2vHE,cAAe,IAAI,MAAM,KACzB,cAAe,IAAI,IAAI,EAAE,EDZ1B,kC6B92HD,wC7B+2HD,wC6B72HG,oBAAA,MAIE,c7B+2HL,MAAA,K6B52HK,gB7B+2HL,cAAA,ICgBD,iBACE,YAAa,I4Bv3HP,uBAQR,6B7Bo2HC,6B6Bl2HG,MAAA,K7Bq2HH,iBAAA,Q6Bn2HK,gBACA,MAAA,KAYN,mBACE,WAAA,I7B41HD,YAAA,E6Bz1HG,e7B41HH,MAAA,K6B11HK,kBACA,MAAA,KAPN,oBAYI,cAAA,IACA,WAAA,OAYJ,wCA0DE,IAAK,KAjED,KAAA,K7B21HH,yB6B1xHD,kBA9DM,QAAA,W7B21HL,MAAA,G6Bl1HD,oBACA,cAAA,GAIE,oBACA,cAAA,EANJ,yB5B02HE,aAAc,EACd,cAAe,IDZhB,8B6B1yHD,oCA3BA,oCATM,OAAA,IAAA,MAAA,K7Bm1HH,yB6B/yHD,yB5B+zHE,cAAe,IAAI,MAAM,KACzB,cAAe,IAAI,IAAI,EAAE,EDZ1B,8B6Bx0HD,oC7By0HD,oC6Bv0HG,oBAAA,MAGA,uB7B00HH,QAAA,K6B/zHC,qBF3OA,QAAA,M3B+iID,yB8BxiIC,WAAY,KACZ,uBAAA,EACA,wBAAA,EAEA,Q9B0iID,SAAA,S8BliIC,WAAY,KA8nBZ,cAAe,KAhoBb,OAAA,IAAA,MAAA,Y9ByiIH,yB8BzhIC,QAgnBE,cAAe,K9B86GlB,yB8BjhIC,eACA,MAAA,MAGA,iBACA,cAAA,KAAA,aAAA,KAEA,WAAA,Q9BkhID,2BAAA,M8BhhIC,WAAA,IAAA,MAAA,YACE,mBAAA,MAAA,EAAA,IAAA,EAAA,qB9BkhIH,WAAA,MAAA,EAAA,IAAA,EAAA,qB8Bz7GD,oBArlBI,WAAA,KAEA,yBAAA,iB9BkhID,MAAA,K8BhhIC,WAAA,EACE,mBAAA,KACA,WAAA,KAEA,0B9BkhIH,QAAA,gB8B/gIC,OAAA,eACE,eAAA,E9BihIH,SAAA,kBCkBD,oBACE,WAAY,QDZf,sC8B/gIK,mC9B8gIH,oC8BzgIC,cAAe,E7B4hIf,aAAc,G6Bj+GlB,sCAnjBE,mC7ByhIA,WAAY,MDdX,4D8BngID,sC9BogID,mCCkBG,WAAY,O6B3gId,kCANE,gC9BsgIH,4B8BvgIG,0BAuiBF,aAAc,M7Bm/Gd,YAAa,MAEf,yBDZC,kC8B3gIK,gC9B0gIH,4B8B3gIG,0BAcF,aAAc,EAChB,YAAA,GAMF,mBA8gBE,QAAS,KAhhBP,aAAA,EAAA,EAAA,I9BkgIH,yB8B7/HC,mB7B+gIE,cAAe,G6B1gIjB,qBADA,kB9BggID,SAAA,M8Bz/HC,MAAO,EAggBP,KAAM,E7B4gHN,QAAS,KDdR,yB8B7/HD,qB9B8/HD,kB8B7/HC,cAAA,GAGF,kBACE,IAAA,EACA,aAAA,EAAA,EAAA,I9BigID,qB8B1/HC,OAAQ,EACR,cAAA,EACA,aAAA,IAAA,EAAA,EAEA,cACA,MAAA,K9B4/HD,OAAA,K8B1/HC,QAAA,KAAA,K7B4gIA,UAAW,K6B1gIT,YAAA,KAIA,oBAbJ,oB9BwgIC,gBAAA,K8Bv/HG,kB7B0gIF,QAAS,MDdR,yBACF,iC8Bh/HC,uCACA,YAAA,OAGA,eC9LA,SAAA,SACA,MAAA,MD+LA,QAAA,IAAA,KACA,WAAA,IACA,aAAA,KACA,cAAA,I9Bm/HD,iBAAA,Y8B/+HC,iBAAA,KACE,OAAA,IAAA,MAAA,Y9Bi/HH,cAAA,I8B5+HG,qBACA,QAAA,EAEA,yB9B++HH,QAAA,M8BrgIC,MAAO,KAyBL,OAAA,I9B++HH,cAAA,I8BpjHD,mCAvbI,WAAA,I9Bg/HH,yB8Bt+HC,eACA,QAAA,MAGE,YACA,OAAA,MAAA,M9By+HH,iB8B58HC,YAAA,KA2YA,eAAgB,KAjaZ,YAAA,KAEA,yBACA,iCACA,SAAA,OACA,MAAA,KACA,MAAA,KAAA,WAAA,E9Bs+HH,iBAAA,Y8B3kHC,OAAQ,E7B8lHR,mBAAoB,K6Bt/HhB,WAAA,KAGA,kDAqZN,sC9BklHC,QAAA,IAAA,KAAA,IAAA,KCmBD,sC6Bv/HQ,YAAA,KAmBR,4C9Bs9HD,4C8BvlHG,iBAAkB,M9B4lHnB,yB8B5lHD,YAtYI,MAAA,K9Bq+HH,OAAA,E8Bn+HK,eACA,MAAA,K9Bu+HP,iB8B39HG,YAAa,KACf,eAAA,MAGA,aACA,QAAA,KAAA,K1B9NA,WAAA,IACQ,aAAA,M2B/DR,cAAA,IACA,YAAA,M/B4vID,WAAA,IAAA,MAAA,YiBtuHC,cAAe,IAAI,MAAM,YAwEzB,mBAAoB,MAAM,EAAE,IAAI,EAAE,qBAAyB,EAAE,IAAI,EAAE,qBAtI/D,WAAA,MAAA,EAAA,IAAA,EAAA,qBAAA,EAAA,IAAA,EAAA,qBAEA,yBjBwyHH,yBiBpqHC,QAAS,aA/HP,cAAA,EACA,eAAA,OjBuyHH,2BiBzqHC,QAAS,aAxHP,MAAA,KjBoyHH,eAAA,OiBhyHG,kCACA,QAAA,aAmHJ,0BhBmsHE,QAAS,aACT,eAAgB,OgB5yHd,wCjB6xHH,6CiBrrHD,2CjBwrHC,MAAA,KiB5xHG,wCACA,MAAA,KAmGJ,4BhB+sHE,cAAe,EgB3yHb,eAAA,OAGA,uBADA,oBjB6xHH,QAAA,aiBnsHC,WAAY,EhBstHZ,cAAe,EgB5yHX,eAAA,OAsFN,6BAAA,0BAjFI,aAAA,EAiFJ,4CjB4sHC,sCiBvxHG,SAAA,SjB0xHH,YAAA,E8BngID,kDAmWE,IAAK,GAvWH,yBACE,yB9B8gIL,cAAA,I8B5/HD,oCAoVE,cAAe,GA1Vf,yBACA,aACA,MAAA,KACA,YAAA,E1BzPF,eAAA,EACQ,aAAA,EJmwIP,YAAA,EACF,OAAA,E8BngIG,mBAAoB,KACtB,WAAA,M9BugID,8B8BngIC,WAAY,EACZ,uBAAA,EHzUA,wBAAA,EAQA,mDACC,cAAA,E3By0IF,uBAAA,I8B//HC,wBAAyB,IChVzB,2BAAA,EACA,0BAAA,EDkVA,YCnVA,WAAA,IACA,cAAA,IDqVA,mBCtVA,WAAA,KACA,cAAA,KD+VF,mBChWE,WAAA,KACA,cAAA,KDuWF,aAsSE,WAAY,KA1SV,cAAA,KAEA,yB9B+/HD,aACF,MAAA,K8Bl+HG,aAAc,KAhBhB,YAAA,MACA,yBE5WA,aF8WE,MAAA,eAFF,cAKI,MAAA,gB9Bu/HH,aAAA,M8B7+HD,4BACA,aAAA,GADF,gBAKI,iBAAA,Q9Bg/HH,aAAA,QCmBD,8B6BhgIM,MAAA,KARN,oC9B0/HC,oC8B5+HG,MAAA,Q9B++HH,iBAAA,Y8B1+HK,6B9B6+HL,MAAA,KCmBD,iC6B5/HQ,MAAA,KAKF,uC9By+HL,uCCmBC,MAAO,KACP,iBAAkB,Y6Bz/HZ,sCAIF,4C9Bu+HL,4CCmBC,MAAO,KACP,iBAAkB,Q6Bv/HZ,wCAxCR,8C9BihIC,8C8Bn+HG,MAAA,K9Bs+HH,iBAAA,YCmBD,+B6Bt/HM,aAAA,KAGA,qCApDN,qC9B2hIC,iBAAA,KCmBD,yC6Bp/HI,iBAAA,KAOE,iCAAA,6B7Bk/HJ,aAAc,Q6B9+HR,oCAiCN,0C9B+7HD,0C8B3xHC,MAAO,KA7LC,iBAAA,QACA,yB7B8+HR,sD6B5+HU,MAAA,KAKF,4D9By9HP,4DCmBC,MAAO,KACP,iBAAkB,Y6Bz+HV,2DAIF,iE9Bu9HP,iECmBC,MAAO,KACP,iBAAkB,Q6Bv+HV,6D9B09HX,mEADE,mE8B1jIC,MAAO,KA8GP,iBAAA,aAEE,6B9Bi9HL,MAAA,K8B58HG,mC9B+8HH,MAAA,KCmBD,0B6B/9HM,MAAA,KAIA,gCAAA,gC7Bg+HJ,MAAO,K6Bt9HT,0CARQ,0CASN,mD9Bu8HD,mD8Bt8HC,MAAA,KAFF,gBAKI,iBAAA,K9B08HH,aAAA,QCmBD,8B6B19HM,MAAA,QARN,oC9Bo9HC,oC8Bt8HG,MAAA,K9By8HH,iBAAA,Y8Bp8HK,6B9Bu8HL,MAAA,QCmBD,iC6Bt9HQ,MAAA,QAKF,uC9Bm8HL,uCCmBC,MAAO,KACP,iBAAkB,Y6Bn9HZ,sCAIF,4C9Bi8HL,4CCmBC,MAAO,KACP,iBAAkB,Q6Bj9HZ,wCAxCR,8C9B2+HC,8C8B57HG,MAAA,K9B+7HH,iBAAA,YCmBD,+B6B/8HM,aAAA,KAGA,qCArDN,qC9Bq/HC,iBAAA,KCmBD,yC6B78HI,iBAAA,KAME,iCAAA,6B7B48HJ,aAAc,Q6Bx8HR,oCAuCN,0C9Bm5HD,0C8B33HC,MAAO,KAvDC,iBAAA,QAuDV,yBApDU,kE9Bs7HP,aAAA,Q8Bn7HO,0D9Bs7HP,iBAAA,QCmBD,sD6Bt8HU,MAAA,QAKF,4D9Bm7HP,4DCmBC,MAAO,KACP,iBAAkB,Y6Bn8HV,2DAIF,iE9Bi7HP,iECmBC,MAAO,KACP,iBAAkB,Q6Bj8HV,6D9Bo7HX,mEADE,mE8B1hIC,MAAO,KA+GP,iBAAA,aAEE,6B9Bg7HL,MAAA,Q8B36HG,mC9B86HH,MAAA,KCmBD,0B6B97HM,MAAA,QAIA,gCAAA,gC7B+7HJ,MAAO,KgCvkJT,0CH0oBQ,0CGzoBN,mDjCwjJD,mDiCvjJC,MAAA,KAEA,YACA,QAAA,IAAA,KjC2jJD,cAAA,KiChkJC,WAAY,KAQV,iBAAA,QjC2jJH,cAAA,IiCxjJK,eACA,QAAA,ajC4jJL,yBiCxkJC,QAAS,EAAE,IAkBT,MAAA,KjCyjJH,QAAA,SkC5kJC,oBACA,MAAA,KAEA,YlC+kJD,QAAA,akCnlJC,aAAc,EAOZ,OAAA,KAAA,ElC+kJH,cAAA,ICmBD,eiC/lJM,QAAA,OAEA,iBACA,oBACA,SAAA,SACA,MAAA,KACA,QAAA,IAAA,KACA,YAAA,KACA,YAAA,WlCglJL,MAAA,QkC9kJG,gBAAA,KjCimJF,iBAAkB,KiC9lJZ,OAAA,IAAA,MAAA,KPVH,6B3B2lJJ,gCkC7kJG,YAAA,EjCgmJF,uBAAwB,I0BvnJxB,0BAAA,I3BymJD,4BkCxkJG,+BjC2lJF,wBAAyB,IACzB,2BAA4B,IiCxlJxB,uBAFA,uBAGA,0BAFA,0BlC8kJL,QAAA,EkCtkJG,MAAA,QjCylJF,iBAAkB,KAClB,aAAc,KAEhB,sBiCvlJM,4BAFA,4BjC0lJN,yBiCvlJM,+BAFA,+BAGA,QAAA,ElC2kJL,MAAA,KkCloJC,OAAQ,QjCqpJR,iBAAkB,QAClB,aAAc,QiCnlJV,wBAEA,8BADA,8BjColJN,2BiCtlJM,iCjCulJN,iCDZC,MAAA,KkC/jJC,OAAQ,YjCklJR,iBAAkB,KkC7pJd,aAAA,KAEA,oBnC8oJL,uBmC5oJG,QAAA,KAAA,KlC+pJF,UAAW,K0B1pJX,YAAA,U3B4oJD,gCmC3oJG,mClC8pJF,uBAAwB,I0BvqJxB,0BAAA,I3BypJD,+BkC1kJD,kCjC6lJE,wBAAyB,IkC7qJrB,2BAAA,IAEA,oBnC8pJL,uBmC5pJG,QAAA,IAAA,KlC+qJF,UAAW,K0B1qJX,YAAA,I3B4pJD,gCmC3pJG,mClC8qJF,uBAAwB,I0BvrJxB,0BAAA,I3ByqJD,+BoC3qJD,kCACE,wBAAA,IACA,2BAAA,IAEA,OpC6qJD,aAAA,EoCjrJC,OAAQ,KAAK,EAOX,WAAA,OpC6qJH,WAAA,KCmBD,UmC7rJM,QAAA,OAEA,YACA,eACA,QAAA,apC8qJL,QAAA,IAAA,KoC5rJC,iBAAkB,KnC+sJlB,OAAQ,IAAI,MAAM,KmC5rJd,cAAA,KAnBN,kBpCisJC,kBCmBC,gBAAiB,KmCzrJb,iBAAA,KA3BN,eAAA,kBAkCM,MAAA,MAlCN,mBAAA,sBnC6tJE,MAAO,KmClrJH,mBAEA,yBADA,yBpCqqJL,sBqCltJC,MAAO,KACP,OAAA,YACA,iBAAA,KAEA,OACA,QAAA,OACA,QAAA,KAAA,KAAA,KACA,UAAA,IACA,YAAA,IACA,YAAA,EACA,MAAA,KrCotJD,WAAA,OqChtJG,YAAA,OpCmuJF,eAAgB,SoCjuJZ,cAAA,MrCotJL,cqCltJK,cAKJ,MAAA,KACE,gBAAA,KrC+sJH,OAAA,QqC1sJG,aACA,QAAA,KAOJ,YCtCE,SAAA,StC+uJD,IAAA,KCmBD,eqC7vJM,iBAAA,KALJ,2BD0CF,2BrC4sJC,iBAAA,QCmBD,eqCpwJM,iBAAA,QALJ,2BD8CF,2BrC+sJC,iBAAA,QCmBD,eqC3wJM,iBAAA,QALJ,2BDkDF,2BrCktJC,iBAAA,QCmBD,YqClxJM,iBAAA,QALJ,wBDsDF,wBrCqtJC,iBAAA,QCmBD,eqCzxJM,iBAAA,QALJ,2BD0DF,2BrCwtJC,iBAAA,QCmBD,cqChyJM,iBAAA,QCDJ,0BADF,0BAEE,iBAAA,QAEA,OACA,QAAA,aACA,UAAA,KACA,QAAA,IAAA,IACA,UAAA,KACA,YAAA,IACA,YAAA,EACA,MAAA,KACA,WAAA,OvCqxJD,YAAA,OuClxJC,eAAA,OACE,iBAAA,KvCoxJH,cAAA,KuC/wJG,aACA,QAAA,KAGF,YtCkyJA,SAAU,SsChyJR,IAAA,KAMA,0BvC4wJH,eCmBC,IAAK,EsC7xJD,QAAA,IAAA,IvCgxJL,cuC9wJK,cAKJ,MAAA,KtC4xJA,gBAAiB,KsC1xJf,OAAA,QvC4wJH,+BuCxwJC,4BACE,MAAA,QvC0wJH,iBAAA,KuCtwJG,wBvCywJH,MAAA,MuCrwJG,+BvCwwJH,aAAA,IwCj0JC,uBACA,YAAA,IAEA,WACA,YAAA,KxCo0JD,eAAA,KwCz0JC,cAAe,KvC41Jf,MAAO,QuCn1JL,iBAAA,KAIA,eAbJ,cAcI,MAAA,QxCo0JH,awCl1JC,cAAe,KAmBb,UAAA,KxCk0JH,YAAA,ICmBD,cuCh1JI,iBAAA,QAEA,sBxCi0JH,4BwC31JC,cAAe,KA8Bb,aAAA,KxCg0JH,cAAA,IwC7yJD,sBAfI,UAAA,KxCi0JD,oCwC9zJC,WvCi1JA,YAAa,KuC/0JX,eAAA,KxCi0JH,sBwCvzJD,4BvC00JE,cAAe,KuC90Jb,aAAA,KC5CJ,ezC42JD,cyC32JC,UAAA,MAGA,WACA,QAAA,MACA,QAAA,IACA,cAAA,KrCiLA,YAAA,WACK,iBAAA,KACG,OAAA,IAAA,MAAA,KJ8rJT,cAAA,IyCx3JC,mBAAoB,OAAO,IAAI,YxC24J1B,cAAe,OAAO,IAAI,YwC93J7B,WAAA,OAAA,IAAA,YAKF,iBzC22JD,eCmBC,aAAc,KACd,YAAa,KwCv3JX,mBA1BJ,kBzCk4JC,kByCv2JG,aAAA,QCzBJ,oBACE,QAAA,IACA,MAAA,KAEA,O1Cs4JD,QAAA,K0C14JC,cAAe,KAQb,OAAA,IAAA,MAAA,YAEA,cAAA,IAVJ,UAeI,WAAA,E1Ck4JH,MAAA,QCmBD,mByC/4JI,YAAA,IArBJ,SAyBI,U1C+3JH,cAAA,ECmBD,WyCx4JE,WAAA,IAFF,mBAAA,mBAMI,cAAA,KAEA,0BACA,0B1Cy3JH,SAAA,S0Cj3JC,IAAK,KCvDL,MAAA,MACA,MAAA,Q3C46JD,e0Ct3JC,MAAO,QClDL,iBAAA,Q3C26JH,aAAA,Q2Cx6JG,kB3C26JH,iBAAA,Q2Cn7JC,2BACA,MAAA,Q3Cu7JD,Y0C73JC,MAAO,QCtDL,iBAAA,Q3Cs7JH,aAAA,Q2Cn7JG,e3Cs7JH,iBAAA,Q2C97JC,wBACA,MAAA,Q3Ck8JD,e0Cp4JC,MAAO,QC1DL,iBAAA,Q3Ci8JH,aAAA,Q2C97JG,kB3Ci8JH,iBAAA,Q2Cz8JC,2BACA,MAAA,Q3C68JD,c0C34JC,MAAO,QC9DL,iBAAA,Q3C48JH,aAAA,Q2Cz8JG,iB3C48JH,iBAAA,Q4C78JC,0BAAQ,MAAA,QACR,wCAAQ,K5Cm9JP,oBAAA,KAAA,E4C/8JD,GACA,oBAAA,EAAA,GACA,mCAAQ,K5Cq9JP,oBAAA,KAAA,E4Cv9JD,GACA,oBAAA,EAAA,GACA,gCAAQ,K5Cq9JP,oBAAA,KAAA,E4C78JD,GACA,oBAAA,EAAA,GAGA,UACA,OAAA,KxCsCA,cAAA,KACQ,SAAA,OJ26JT,iBAAA,Q4C78JC,cAAe,IACf,mBAAA,MAAA,EAAA,IAAA,IAAA,eACA,WAAA,MAAA,EAAA,IAAA,IAAA,eAEA,cACA,MAAA,KACA,MAAA,EACA,OAAA,KACA,UAAA,KxCyBA,YAAA,KACQ,MAAA,KAyHR,WAAA,OACK,iBAAA,QACG,mBAAA,MAAA,EAAA,KAAA,EAAA,gBJ+zJT,WAAA,MAAA,EAAA,KAAA,EAAA,gB4C18JC,mBAAoB,MAAM,IAAI,K3Cq+JzB,cAAe,MAAM,IAAI,K4Cp+J5B,WAAA,MAAA,IAAA,KDEF,sBCAE,gCDAF,iBAAA,yK5C88JD,iBAAA,oK4Cv8JC,iBAAiB,iK3Cm+JjB,wBAAyB,KAAK,KG/gK9B,gBAAA,KAAA,KJy/JD,qBIv/JS,+BwCmDR,kBAAmB,qBAAqB,GAAG,OAAO,SErElD,aAAA,qBAAA,GAAA,OAAA,S9C4gKD,UAAA,qBAAA,GAAA,OAAA,S6Cz9JG,sBACA,iBAAA,Q7C69JH,wC4Cx8JC,iBAAkB,yKEzElB,iBAAA,oK9CohKD,iBAAA,iK6Cj+JG,mBACA,iBAAA,Q7Cq+JH,qC4C58JC,iBAAkB,yKE7ElB,iBAAA,oK9C4hKD,iBAAA,iK6Cz+JG,sBACA,iBAAA,Q7C6+JH,wC4Ch9JC,iBAAkB,yKEjFlB,iBAAA,oK9CoiKD,iBAAA,iK6Cj/JG,qBACA,iBAAA,Q7Cq/JH,uC+C5iKC,iBAAkB,yKAElB,iBAAA,oK/C6iKD,iBAAA,iK+C1iKG,O/C6iKH,WAAA,KC4BD,mB8CnkKE,WAAA,E/C4iKD,O+CxiKD,YACE,SAAA,O/C0iKD,KAAA,E+CtiKC,Y/CyiKD,MAAA,Q+CriKG,c/CwiKH,QAAA,MC4BD,4B8C9jKE,UAAA,KAGF,aAAA,mBAEE,aAAA,KAGF,YAAA,kB9C+jKE,cAAe,K8CxjKjB,YAHE,Y/CoiKD,a+ChiKC,QAAA,W/CmiKD,eAAA,I+C/hKC,c/CkiKD,eAAA,O+C7hKC,cACA,eAAA,OAMF,eACE,WAAA,EACA,cAAA,ICvDF,YAEE,aAAA,EACA,WAAA,KAQF,YACE,aAAA,EACA,cAAA,KAGA,iBACA,SAAA,SACA,QAAA,MhD6kKD,QAAA,KAAA,KgD1kKC,cAAA,KrB3BA,iBAAA,KACC,OAAA,IAAA,MAAA,KqB6BD,6BACE,uBAAA,IrBvBF,wBAAA,I3BsmKD,4BgDpkKC,cAAe,E/CgmKf,2BAA4B,I+C9lK5B,0BAAA,IAFF,kBAAA,uBAKI,MAAA,KAIF,2CAAA,gD/CgmKA,MAAO,K+C5lKL,wBAFA,wBhDykKH,6BgDxkKG,6BAKF,MAAO,KACP,gBAAA,KACA,iBAAA,QAKA,uB/C4lKA,MAAO,KACP,WAAY,K+CzlKV,0BhDmkKH,gCgDlkKG,gCALF,MAAA,K/CmmKA,OAAQ,YACR,iBAAkB,KDxBnB,mDgD5kKC,yDAAA,yD/CymKA,MAAO,QDxBR,gDgDhkKC,sDAAA,sD/C6lKA,MAAO,K+CzlKL,wBAEA,8BADA,8BhDmkKH,QAAA,EgDxkKC,MAAA,K/ComKA,iBAAkB,QAClB,aAAc,QAEhB,iDDpBC,wDCuBD,uDADA,uD+CzmKE,8DAYI,6D/C4lKN,uD+CxmKE,8D/C2mKF,6DAKE,MAAO,QDxBR,8CiD1qKG,oDADF,oDAEE,MAAA,QAEA,yBhDusKF,MAAO,QgDrsKH,iBAAA,QAFF,0BAAA,+BAKI,MAAA,QAGF,mDAAA,wDhDwsKJ,MAAO,QDtBR,gCiDhrKO,gCAGF,qCAFE,qChD2sKN,MAAO,QACP,iBAAkB,QAEpB,iCgDvsKQ,uCAFA,uChD0sKR,sCDtBC,4CiDnrKO,4CArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,sBhDouKF,MAAO,QgDluKH,iBAAA,QAFF,uBAAA,4BAKI,MAAA,QAGF,gDAAA,qDhDquKJ,MAAO,QDtBR,6BiD7sKO,6BAGF,kCAFE,kChDwuKN,MAAO,QACP,iBAAkB,QAEpB,8BgDpuKQ,oCAFA,oChDuuKR,mCDtBC,yCiDhtKO,yCArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,yBhDiwKF,MAAO,QgD/vKH,iBAAA,QAFF,0BAAA,+BAKI,MAAA,QAGF,mDAAA,wDhDkwKJ,MAAO,QDtBR,gCiD1uKO,gCAGF,qCAFE,qChDqwKN,MAAO,QACP,iBAAkB,QAEpB,iCgDjwKQ,uCAFA,uChDowKR,sCDtBC,4CiD7uKO,4CArBN,MAAA,KACE,iBAAA,QACA,aAAA,QAEA,wBhD8xKF,MAAO,QgD5xKH,iBAAA,QAFF,yBAAA,8BAKI,MAAA,QAGF,kDAAA,uDhD+xKJ,MAAO,QDtBR,+BiDvwKO,+BAGF,oCAFE,oChDkyKN,MAAO,QACP,iBAAkB,QAEpB,gCgD9xKQ,sCAFA,sChDiyKR,qCDtBC,2CiD1wKO,2CDkGN,MAAO,KACP,iBAAA,QACA,aAAA,QAEF,yBACE,WAAA,EACA,cAAA,IE1HF,sBACE,cAAA,EACA,YAAA,IAEA,O9C0DA,cAAA,KACQ,iBAAA,KJ6uKT,OAAA,IAAA,MAAA,YkDnyKC,cAAe,IACf,mBAAA,EAAA,IAAA,IAAA,gBlDqyKD,WAAA,EAAA,IAAA,IAAA,gBkD/xKC,YACA,QAAA,KvBnBC,e3BuzKF,QAAA,KAAA,KkDtyKC,cAAe,IAAI,MAAM,YAMvB,uBAAA,IlDmyKH,wBAAA,IkD7xKC,0CACA,MAAA,QAEA,alDgyKD,WAAA,EkDpyKC,cAAe,EjDg0Kf,UAAW,KACX,MAAO,QDtBR,oBkD1xKC,sBjDkzKF,eiDxzKI,mBAKJ,qBAEE,MAAA,QvBvCA,cACC,QAAA,KAAA,K3Bs0KF,iBAAA,QkDrxKC,WAAY,IAAI,MAAM,KjDizKtB,2BAA4B,IiD9yK1B,0BAAA,IAHJ,mBAAA,mCAMM,cAAA,ElDwxKL,oCkDnxKG,oDjD+yKF,aAAc,IAAI,EiD7yKZ,cAAA,EvBtEL,4D3B61KF,4EkDjxKG,WAAA,EjD6yKF,uBAAwB,IiD3yKlB,wBAAA,IvBtEL,0D3B21KF,0EkD1yKC,cAAe,EvB1Df,2BAAA,IACC,0BAAA,IuB0FH,+EAEI,uBAAA,ElD8wKH,wBAAA,EkD1wKC,wDlD6wKD,iBAAA,EC4BD,0BACE,iBAAkB,EiDlyKpB,8BlD0wKC,ckD1wKD,gCjDuyKE,cAAe,EiDvyKjB,sCAQM,sBlDwwKL,wCC4BC,cAAe,K0Br5Kf,aAAA,KuByGF,wDlDqxKC,0BC4BC,uBAAwB,IACxB,wBAAyB,IiDlzK3B,yFAoBQ,yFlDwwKP,2DkDzwKO,2DjDqyKN,uBAAwB,IACxB,wBAAyB,IAK3B,wGiD9zKA,wGjD4zKA,wGDtBC,wGCuBD,0EiD7zKA,0EjD2zKA,0EiDnyKU,0EjD2yKR,uBAAwB,IAK1B,uGiDx0KA,uGjDs0KA,uGDtBC,uGCuBD,yEiDv0KA,yEjDq0KA,yEiDzyKU,yEvB7HR,wBAAA,IuBiGF,sDlDqzKC,yBC4BC,2BAA4B,IAC5B,0BAA2B,IiDxyKrB,qFA1CR,qFAyCQ,wDlDmxKP,wDC4BC,2BAA4B,IAC5B,0BAA2B,IAG7B,oGDtBC,oGCwBD,oGiD91KA,oGjD21KA,uEiD7yKU,uEjD+yKV,uEiD71KA,uEjDm2KE,0BAA2B,IAG7B,mGDtBC,mGCwBD,mGiDx2KA,mGjDq2KA,sEiDnzKU,sEjDqzKV,sEiDv2KA,sEjD62KE,2BAA4B,IiDlzK1B,0BlD2xKH,qCkDt1KD,0BAAA,qCA+DI,WAAA,IAAA,MAAA,KA/DJ,kDAAA,kDAmEI,WAAA,EAnEJ,uBAAA,yCjD23KE,OAAQ,EiDjzKA,+CjDqzKV,+CiD/3KA,+CjDi4KA,+CAEA,+CANA,+CDjBC,iECoBD,iEiDh4KA,iEjDk4KA,iEAEA,iEANA,iEAWE,YAAa,EiD3zKL,8CjD+zKV,8CiD74KA,8CjD+4KA,8CAEA,8CANA,8CDjBC,gECoBD,gEiD94KA,gEjDg5KA,gEAEA,gEANA,gEAWE,aAAc,EAIhB,+CiD35KA,+CjDy5KA,+CiDl0KU,+CjDq0KV,iEiD55KA,iEjD05KA,iEDtBC,iEC6BC,cAAe,EAEjB,8CiDn0KU,8CjDq0KV,8CiDr6KA,8CjDo6KA,gEDtBC,gECwBD,gEiDh0KI,gEACA,cAAA,EAUJ,yBACE,cAAA,ElDmyKD,OAAA,EkD/xKG,aACA,cAAA,KANJ,oBASM,cAAA,ElDkyKL,cAAA,IkD7xKG,2BlDgyKH,WAAA,IC4BD,4BiDxzKM,cAAA,EAKF,wDAvBJ,wDlDqzKC,WAAA,IAAA,MAAA,KkD5xKK,2BlD+xKL,WAAA,EmDlhLC,uDnDqhLD,cAAA,IAAA,MAAA,KmDlhLG,eACA,aAAA,KnDshLH,8BmDxhLC,MAAA,KAMI,iBAAA,QnDqhLL,aAAA,KmDlhLK,0DACA,iBAAA,KAGJ,qCAEI,MAAA,QnDmhLL,iBAAA,KmDpiLC,yDnDuiLD,oBAAA,KmDpiLG,eACA,aAAA,QnDwiLH,8BmD1iLC,MAAA,KAMI,iBAAA,QnDuiLL,aAAA,QmDpiLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnDqiLL,iBAAA,KmDtjLC,yDnDyjLD,oBAAA,QmDtjLG,eACA,aAAA,QnD0jLH,8BmD5jLC,MAAA,QAMI,iBAAA,QnDyjLL,aAAA,QmDtjLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnDujLL,iBAAA,QmDxkLC,yDnD2kLD,oBAAA,QmDxkLG,YACA,aAAA,QnD4kLH,2BmD9kLC,MAAA,QAMI,iBAAA,QnD2kLL,aAAA,QmDxkLK,uDACA,iBAAA,QAGJ,kCAEI,MAAA,QnDykLL,iBAAA,QmD1lLC,sDnD6lLD,oBAAA,QmD1lLG,eACA,aAAA,QnD8lLH,8BmDhmLC,MAAA,QAMI,iBAAA,QnD6lLL,aAAA,QmD1lLK,0DACA,iBAAA,QAGJ,qCAEI,MAAA,QnD2lLL,iBAAA,QmD5mLC,yDnD+mLD,oBAAA,QmD5mLG,cACA,aAAA,QnDgnLH,6BmDlnLC,MAAA,QAMI,iBAAA,QnD+mLL,aAAA,QmD5mLK,yDACA,iBAAA,QAGJ,oCAEI,MAAA,QnD6mLL,iBAAA,QoD5nLC,wDACA,oBAAA,QAEA,kBACA,SAAA,SpD+nLD,QAAA,MoDpoLC,OAAQ,EnDgqLR,QAAS,EACT,SAAU,OAEZ,yCmDtpLI,wBADA,yBAEA,yBACA,wBACA,SAAA,SACA,IAAA,EACA,OAAA,EpD+nLH,KAAA,EoD1nLC,MAAO,KACP,OAAA,KpD4nLD,OAAA,EoDvnLC,wBpD0nLD,eAAA,OqDppLC,uBACA,eAAA,IAEA,MACA,WAAA,KACA,QAAA,KjDwDA,cAAA,KACQ,iBAAA,QJgmLT,OAAA,IAAA,MAAA,QqD/pLC,cAAe,IASb,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACA,WAAA,MAAA,EAAA,IAAA,IAAA,gBAKJ,iBACE,aAAA,KACA,aAAA,gBAEF,SACE,QAAA,KACA,cAAA,ICtBF,SACE,QAAA,IACA,cAAA,IAEA,OACA,MAAA,MACA,UAAA,KjCRA,YAAA,IAGA,YAAA,ErBqrLD,MAAA,KsD7qLC,YAAA,EAAA,IAAA,EAAA,KrDysLA,OAAQ,kBqDvsLN,QAAA,GjCbF,aiCeE,ajCZF,MAAA,KrB6rLD,gBAAA,KsDzqLC,OAAA,QACE,OAAA,kBACA,QAAA,GAEA,aACA,mBAAA,KtD2qLH,QAAA,EuDhsLC,OAAQ,QACR,WAAA,IvDksLD,OAAA,EuD7rLC,YACA,SAAA,OAEA,OACA,SAAA,MACA,IAAA,EACA,MAAA,EACA,OAAA,EACA,KAAA,EAIA,QAAA,KvD6rLD,QAAA,KuD1rLC,SAAA,OnD+GA,2BAAA,MACI,QAAA,EAEI,0BAkER,mBAAA,kBAAA,IAAA,SAEK,cAAA,aAAA,IAAA,SACG,WAAA,UAAA,IAAA,SJ6gLT,kBAAA,kBuDhsLC,cAAA,kBnD2GA,aAAA,kBACI,UAAA,kBAEI,wBJwlLT,kBAAA,euDpsLK,cAAe,eACnB,aAAA,eACA,UAAA,eAIF,mBACE,WAAA,OACA,WAAA,KvDqsLD,cuDhsLC,SAAU,SACV,MAAA,KACA,OAAA,KAEA,eACA,SAAA,SnDaA,iBAAA,KACQ,wBAAA,YmDZR,gBAAA,YtD4tLA,OsD5tLA,IAAA,MAAA,KAEA,OAAA,IAAA,MAAA,evDksLD,cAAA,IuD9rLC,QAAS,EACT,mBAAA,EAAA,IAAA,IAAA,eACA,WAAA,EAAA,IAAA,IAAA,eAEA,gBACA,SAAA,MACA,IAAA,EACA,MAAA,EvDgsLD,OAAA,EuD9rLC,KAAA,ElCrEA,QAAA,KAGA,iBAAA,KkCmEA,qBlCtEA,OAAA,iBAGA,QAAA,EkCwEF,mBACE,OAAA,kBACA,QAAA,GAIF,cACE,QAAA,KvDgsLD,cAAA,IAAA,MAAA,QuD3rLC,qBACA,WAAA,KAKF,aACE,OAAA,EACA,YAAA,WAIF,YACE,SAAA,SACA,QAAA,KvD0rLD,cuD5rLC,QAAS,KAQP,WAAA,MACA,WAAA,IAAA,MAAA,QATJ,wBAaI,cAAA,EvDsrLH,YAAA,IuDlrLG,mCvDqrLH,YAAA,KuD/qLC,oCACA,YAAA,EAEA,yBACA,SAAA,SvDkrLD,IAAA,QuDhqLC,MAAO,KAZP,OAAA,KACE,SAAA,OvDgrLD,yBuD7qLD,cnDvEA,MAAA,MACQ,OAAA,KAAA,KmD2ER,eAAY,mBAAA,EAAA,IAAA,KAAA,evD+qLX,WAAA,EAAA,IAAA,KAAA,euDzqLD,UAFA,MAAA,OvDirLD,yBwD/zLC,UACA,MAAA,OCNA,SAEA,SAAA,SACA,QAAA,KACA,QAAA,MACA,YAAA,iBAAA,UAAA,MAAA,WACA,UAAA,KACA,WAAA,OACA,YAAA,IACA,YAAA,WACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KACA,eAAA,ODHA,WAAA,OnCVA,aAAA,OAGA,UAAA,OrBs1LD,YAAA,OwD30LC,OAAA,iBnCdA,QAAA,ErB61LD,WAAA,KwD90LY,YAAmB,OAAA,kBxDk1L/B,QAAA,GwDj1LY,aAAmB,QAAA,IAAA,ExDq1L/B,WAAA,KwDp1LY,eAAmB,QAAA,EAAA,IxDw1L/B,YAAA,IwDv1LY,gBAAmB,QAAA,IAAA,ExD21L/B,WAAA,IwDt1LC,cACA,QAAA,EAAA,IACA,YAAA,KAEA,eACA,UAAA,MxDy1LD,QAAA,IAAA,IwDr1LC,MAAO,KACP,WAAA,OACA,iBAAA,KACA,cAAA,IAEA,exDu1LD,SAAA,SwDn1LC,MAAA,EACE,OAAA,EACA,aAAA,YACA,aAAA,MAEA,4BxDq1LH,OAAA,EwDn1LC,KAAA,IACE,YAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,iCxDq1LH,MAAA,IwDn1LC,OAAA,EACE,cAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,kCxDq1LH,OAAA,EwDn1LC,KAAA,IACE,cAAA,KACA,aAAA,IAAA,IAAA,EACA,iBAAA,KAEA,8BxDq1LH,IAAA,IwDn1LC,KAAA,EACE,WAAA,KACA,aAAA,IAAA,IAAA,IAAA,EACA,mBAAA,KAEA,6BxDq1LH,IAAA,IwDn1LC,MAAA,EACE,WAAA,KACA,aAAA,IAAA,EAAA,IAAA,IACA,kBAAA,KAEA,+BxDq1LH,IAAA,EwDn1LC,KAAA,IACE,YAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,oCxDq1LH,IAAA,EwDn1LC,MAAA,IACE,WAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,qCxDq1LH,IAAA,E0Dl7LC,KAAM,IACN,WAAA,KACA,aAAA,EAAA,IAAA,IACA,oBAAA,KAEA,SACA,SAAA,SACA,IAAA,EDXA,KAAA,EAEA,QAAA,KACA,QAAA,KACA,UAAA,MACA,QAAA,IACA,YAAA,iBAAA,UAAA,MAAA,WACA,UAAA,KACA,WAAA,OACA,YAAA,IACA,YAAA,WACA,WAAA,KACA,WAAA,MACA,gBAAA,KACA,YAAA,KACA,eAAA,KCAA,eAAA,OAEA,WAAA,OACA,aAAA,OAAA,UAAA,OACA,YAAA,OACA,iBAAA,KACA,wBAAA,YtD8CA,gBAAA,YACQ,OAAA,IAAA,MAAA,KJk5LT,OAAA,IAAA,MAAA,e0D77LC,cAAA,IAAY,mBAAA,EAAA,IAAA,KAAA,e1Dg8Lb,WAAA,EAAA,IAAA,KAAA,e0D/7La,WAAA,KACZ,aAAY,WAAA,MACZ,eAAY,YAAA,KAGd,gBACE,WAAA,KAEA,cACA,YAAA,MAEA,e1Dq8LD,QAAA,IAAA,K0Dl8LC,OAAQ,EACR,UAAA,K1Do8LD,iBAAA,Q0D57LC,cAAA,IAAA,MAAA,QzDy9LA,cAAe,IAAI,IAAI,EAAE,EyDt9LvB,iBACA,QAAA,IAAA,KAEA,gBACA,sB1D87LH,SAAA,S0D37LC,QAAS,MACT,MAAA,E1D67LD,OAAA,E0D37LC,aAAc,YACd,aAAA,M1D87LD,gB0Dz7LC,aAAA,KAEE,sBACA,QAAA,GACA,aAAA,KAEA,oB1D27LH,OAAA,M0D17LG,KAAA,IACE,YAAA,MACA,iBAAA,KACA,iBAAA,gBACA,oBAAA,E1D67LL,0B0Dz7LC,OAAA,IACE,YAAA,MACA,QAAA,IACA,iBAAA,KACA,oBAAA,EAEA,sB1D27LH,IAAA,I0D17LG,KAAA,MACE,WAAA,MACA,mBAAA,KACA,mBAAA,gBACA,kBAAA,E1D67LL,4B0Dz7LC,OAAA,MACE,KAAA,IACA,QAAA,IACA,mBAAA,KACA,kBAAA,EAEA,uB1D27LH,IAAA,M0D17LG,KAAA,IACE,YAAA,MACA,iBAAA,EACA,oBAAA,KACA,oBAAA,gB1D67LL,6B0Dx7LC,IAAA,IACE,YAAA,MACA,QAAA,IACA,iBAAA,EACA,oBAAA,KAEA,qB1D07LH,IAAA,I0Dz7LG,MAAA,MACE,WAAA,MACA,mBAAA,EACA,kBAAA,KACA,kBAAA,gB1D47LL,2B2DpjMC,MAAO,IACP,OAAA,M3DsjMD,QAAA,I2DnjMC,mBAAoB,EACpB,kBAAA,KAEA,U3DqjMD,SAAA,S2DljMG,gBACA,SAAA,SvD6KF,MAAA,KACK,SAAA,OJ04LN,sB2D/jMC,SAAU,S1D4lMV,QAAS,K0D9kML,mBAAA,IAAA,YAAA,K3DqjML,cAAA,IAAA,YAAA,K2D3hMC,WAAA,IAAA,YAAA,KvDmKK,4BAFL,0BAGQ,YAAA,EA3JA,qDA+GR,sBAEQ,mBAAA,kBAAA,IAAA,YJ86LP,cAAA,aAAA,IAAA,Y2DzjMG,WAAA,UAAA,IAAA,YvDmHJ,4BAAA,OACQ,oBAAA,OuDjHF,oBAAA,O3D4jML,YAAA,OI58LD,mCHs+LA,2BGr+LQ,KAAA,EuD5GF,kBAAA,sB3D6jML,UAAA,sBC2BD,kCADA,2BG5+LA,KAAA,EACQ,kBAAA,uBuDtGF,UAAA,uBArCN,6B3DomMD,gC2DpmMC,iC1D+nME,KAAM,E0DllMN,kBAAA,mB3D4jMH,UAAA,oBAGA,wB2D5mMD,sBAAA,sBAsDI,QAAA,MAEA,wB3D0jMH,KAAA,E2DtjMG,sB3DyjMH,sB2DrnMC,SAAU,SA+DR,IAAA,E3DyjMH,MAAA,KC0BD,sB0D/kMI,KAAA,KAnEJ,sBAuEI,KAAA,MAvEJ,2BA0EI,4B3DwjMH,KAAA,E2D/iMC,6BACA,KAAA,MAEA,8BACA,KAAA,KtC3FA,kBsC6FA,SAAA,SACA,IAAA,EACA,OAAA,EACA,KAAA,EACA,MAAA,I3DmjMD,UAAA,K2D9iMC,MAAA,KdnGE,WAAA,OACA,YAAA,EAAA,IAAA,IAAA,eACA,iBAAA,cAAA,OAAA,kBACA,QAAA,G7CqpMH,uB2DljMC,iBAAA,sEACE,iBAAA,iEACA,iBAAA,uFdxGA,iBAAA,kEACA,OAAA,+GACA,kBAAA,SACA,wBACA,MAAA,E7C6pMH,KAAA,K2DpjMC,iBAAA,sE1DglMA,iBAAiB,iE0D9kMf,iBAAA,uFACA,iBAAA,kEACA,OAAA,+GtCvHF,kBAAA,SsCyFF,wB3DslMC,wBC4BC,MAAO,KACP,gBAAiB,KACjB,OAAQ,kB0D7kMN,QAAA,EACA,QAAA,G3DwjMH,0C2DhmMD,2CA2CI,6BADA,6B1DklMF,SAAU,S0D7kMR,IAAA,IACA,QAAA,E3DqjMH,QAAA,a2DrmMC,WAAY,MAqDV,0CADA,6B3DsjMH,KAAA,I2D1mMC,YAAa,MA0DX,2CADA,6BAEA,MAAA,IACA,aAAA,MAME,6BADF,6B3DmjMH,MAAA,K2D9iMG,OAAA,KACE,YAAA,M3DgjML,YAAA,E2DriMC,oCACA,QAAA,QAEA,oCACA,QAAA,QAEA,qBACA,SAAA,SACA,OAAA,K3DwiMD,KAAA,I2DjjMC,QAAS,GAYP,MAAA,IACA,aAAA,EACA,YAAA,KACA,WAAA,OACA,WAAA,KAEA,wBACA,QAAA,aAWA,MAAA,KACA,OAAA,K3D8hMH,OAAA,I2D7jMC,YAAa,OAkCX,OAAA,QACA,iBAAA,OACA,iBAAA,cACA,OAAA,IAAA,MAAA,K3D8hMH,cAAA,K2DthMC,6BACA,MAAA,KACA,OAAA,KACA,OAAA,EACA,iBAAA,KAEA,kBACA,SAAA,SACA,MAAA,IACA,OAAA,K3DyhMD,KAAA,I2DxhMC,QAAA,GACE,YAAA,K3D0hMH,eAAA,K2Dj/LC,MAAO,KAhCP,WAAA,O1D8iMA,YAAa,EAAE,IAAI,IAAI,eAEzB,uB0D3iMM,YAAA,KAEA,oCACA,0C3DmhMH,2C2D3hMD,6BAAA,6BAYI,MAAA,K3DmhMH,OAAA,K2D/hMD,WAAA,M1D2jME,UAAW,KDxBZ,0C2D9gMD,6BACE,YAAA,MAEA,2C3DghMD,6B2D5gMD,aAAA,M3D+gMC,kBACF,MAAA,I4D7wMC,KAAA,I3DyyME,eAAgB,KAElB,qBACE,OAAQ,MAkBZ,qCADA,sCADA,mBADA,oBAXA,gBADA,iBAOA,uBADA,wBADA,iBADA,kBADA,wBADA,yBASA,mCADA,oC2DpzME,oBAAA,qBAAA,oBAAA,qB3D2zMF,WADA,YAOA,uBADA,wBADA,qBADA,sBADA,cADA,e2D/zMI,a3Dq0MJ,cDvBC,kB4D7yMG,mB3DqzMJ,WADA,YAwBE,QAAS,MACT,QAAS,IASX,qCADA,mBANA,gBAGA,uBADA,iBADA,wBAIA,mCDhBC,oB6D/0MC,oB5Dk2MF,W+B51MA,uBhCo0MC,qB4D5zMG,cChBF,aACA,kB5D+1MF,W+Br1ME,MAAO,KhCy0MR,cgCt0MC,QAAS,MACT,aAAA,KhCw0MD,YAAA,KgC/zMC,YhCk0MD,MAAA,gBgC/zMC,WhCk0MD,MAAA,egC/zMC,MhCk0MD,QAAA,e8Dz1MC,MACA,QAAA,gBAEA,WACA,WAAA,O9B8BF,WACE,KAAA,EAAA,EAAA,EhCg0MD,MAAA,YgCzzMC,YAAa,KACb,iBAAA,YhC2zMD,OAAA,E+D31MC,Q/D81MD,QAAA,eC4BD,OACE,SAAU,M+Dn4MV,chE42MD,MAAA,aC+BD,YADA,YADA,YADA,YAIE,QAAS,e+Dp5MT,kBhEs4MC,mBgEr4MD,yBhEi4MD,kB+Dl1MD,mBA6IA,yB9D4tMA,kBACA,mB8Dj3ME,yB9D62MF,kBACA,mBACA,yB+Dv5MY,QAAA,eACV,yBAAU,YhE04MT,QAAA,gBC4BD,iB+Dp6MU,QAAA,gBhE64MX,c+D51MG,QAAS,oB/Dg2MV,c+Dl2MC,c/Dm2MH,QAAA,sB+D91MG,yB/Dk2MD,kBACF,QAAA,iB+D91MG,yB/Dk2MD,mBACF,QAAA,kBgEh6MC,yBhEo6MC,yBgEn6MD,QAAA,wBACA,+CAAU,YhEw6MT,QAAA,gBC4BD,iB+Dl8MU,QAAA,gBhE26MX,c+Dr2MG,QAAS,oB/Dy2MV,c+D32MC,c/D42MH,QAAA,sB+Dv2MG,+C/D22MD,kBACF,QAAA,iB+Dv2MG,+C/D22MD,mBACF,QAAA,kBgE97MC,+ChEk8MC,yBgEj8MD,QAAA,wBACA,gDAAU,YhEs8MT,QAAA,gBC4BD,iB+Dh+MU,QAAA,gBhEy8MX,c+D92MG,QAAS,oB/Dk3MV,c+Dp3MC,c/Dq3MH,QAAA,sB+Dh3MG,gD/Do3MD,kBACF,QAAA,iB+Dh3MG,gD/Do3MD,mBACF,QAAA,kBgE59MC,gDhEg+MC,yBgE/9MD,QAAA,wBACA,0BAAU,YhEo+MT,QAAA,gBC4BD,iB+D9/MU,QAAA,gBhEu+MX,c+Dv3MG,QAAS,oB/D23MV,c+D73MC,c/D83MH,QAAA,sB+Dz3MG,0B/D63MD,kBACF,QAAA,iB+Dz3MG,0B/D63MD,mBACF,QAAA,kBgEl/MC,0BhEs/MC,yBACF,QAAA,wBgEv/MC,yBhE2/MC,WACF,QAAA,gBgE5/MC,+ChEggNC,WACF,QAAA,gBgEjgNC,gDhEqgNC,WACF,QAAA,gBAGA,0B+Dh3MC,WA4BE,QAAS,gBC5LX,eAAU,QAAA,eACV,aAAU,ehEyhNT,QAAA,gBC4BD,oB+DnjNU,QAAA,gBhE4hNX,iB+D93MG,QAAS,oBAMX,iB/D23MD,iB+Dt2MG,QAAS,sB/D22MZ,qB+D/3MC,QAAS,e/Dk4MV,a+D53MC,qBAcE,QAAS,iB/Dm3MZ,sB+Dh4MC,QAAS,e/Dm4MV,a+D73MC,sBAOE,QAAS,kB/D23MZ,4B+D53MC,QAAS,eCpLT,ahEojNC,4BACF,QAAA,wBC6BD,aACE,cACE,QAAS","sourcesContent":["/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\n\n//\n// 1. Set default font family to sans-serif.\n// 2. Prevent iOS and IE text size adjust after device orientation change,\n// without disabling user zoom.\n//\n\nhtml {\n font-family: sans-serif; // 1\n -ms-text-size-adjust: 100%; // 2\n -webkit-text-size-adjust: 100%; // 2\n}\n\n//\n// Remove default margin.\n//\n\nbody {\n margin: 0;\n}\n\n// HTML5 display definitions\n// ==========================================================================\n\n//\n// Correct `block` display not defined for any HTML5 element in IE 8/9.\n// Correct `block` display not defined for `details` or `summary` in IE 10/11\n// and Firefox.\n// Correct `block` display not defined for `main` in IE 11.\n//\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\n\n//\n// 1. Correct `inline-block` display not defined in IE 8/9.\n// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n//\n\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block; // 1\n vertical-align: baseline; // 2\n}\n\n//\n// Prevent modern browsers from displaying `audio` without controls.\n// Remove excess height in iOS 5 devices.\n//\n\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n\n//\n// Address `[hidden]` styling not present in IE 8/9/10.\n// Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.\n//\n\n[hidden],\ntemplate {\n display: none;\n}\n\n// Links\n// ==========================================================================\n\n//\n// Remove the gray background color from active links in IE 10.\n//\n\na {\n background-color: transparent;\n}\n\n//\n// Improve readability of focused elements when they are also in an\n// active/hover state.\n//\n\na:active,\na:hover {\n outline: 0;\n}\n\n// Text-level semantics\n// ==========================================================================\n\n//\n// Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n//\n\nabbr[title] {\n border-bottom: 1px dotted;\n}\n\n//\n// Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n//\n\nb,\nstrong {\n font-weight: bold;\n}\n\n//\n// Address styling not present in Safari and Chrome.\n//\n\ndfn {\n font-style: italic;\n}\n\n//\n// Address variable `h1` font-size and margin within `section` and `article`\n// contexts in Firefox 4+, Safari, and Chrome.\n//\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n//\n// Address styling not present in IE 8/9.\n//\n\nmark {\n background: #ff0;\n color: #000;\n}\n\n//\n// Address inconsistent and variable font size in all browsers.\n//\n\nsmall {\n font-size: 80%;\n}\n\n//\n// Prevent `sub` and `sup` affecting `line-height` in all browsers.\n//\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsup {\n top: -0.5em;\n}\n\nsub {\n bottom: -0.25em;\n}\n\n// Embedded content\n// ==========================================================================\n\n//\n// Remove border when inside `a` element in IE 8/9/10.\n//\n\nimg {\n border: 0;\n}\n\n//\n// Correct overflow not hidden in IE 9/10/11.\n//\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\n// Grouping content\n// ==========================================================================\n\n//\n// Address margin not present in IE 8/9 and Safari.\n//\n\nfigure {\n margin: 1em 40px;\n}\n\n//\n// Address differences between Firefox and other browsers.\n//\n\nhr {\n box-sizing: content-box;\n height: 0;\n}\n\n//\n// Contain overflow in all browsers.\n//\n\npre {\n overflow: auto;\n}\n\n//\n// Address odd `em`-unit font size rendering in all browsers.\n//\n\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\n\n// Forms\n// ==========================================================================\n\n//\n// Known limitation: by default, Chrome and Safari on OS X allow very limited\n// styling of `select`, unless a `border` property is set.\n//\n\n//\n// 1. Correct color not being inherited.\n// Known issue: affects color of disabled elements.\n// 2. Correct font properties not being inherited.\n// 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n//\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit; // 1\n font: inherit; // 2\n margin: 0; // 3\n}\n\n//\n// Address `overflow` set to `hidden` in IE 8/9/10/11.\n//\n\nbutton {\n overflow: visible;\n}\n\n//\n// Address inconsistent `text-transform` inheritance for `button` and `select`.\n// All other form control elements do not inherit `text-transform` values.\n// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n// Correct `select` style inheritance in Firefox.\n//\n\nbutton,\nselect {\n text-transform: none;\n}\n\n//\n// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n// and `video` controls.\n// 2. Correct inability to style clickable `input` types in iOS.\n// 3. Improve usability and consistency of cursor style between image-type\n// `input` and others.\n//\n\nbutton,\nhtml input[type=\"button\"], // 1\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button; // 2\n cursor: pointer; // 3\n}\n\n//\n// Re-set default cursor for disabled elements.\n//\n\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\n\n//\n// Remove inner padding and border in Firefox 4+.\n//\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\n\n//\n// Address Firefox 4+ setting `line-height` on `input` using `!important` in\n// the UA stylesheet.\n//\n\ninput {\n line-height: normal;\n}\n\n//\n// It's recommended that you don't attempt to style these elements.\n// Firefox's implementation doesn't respect box-sizing, padding, or width.\n//\n// 1. Address box sizing set to `content-box` in IE 8/9/10.\n// 2. Remove excess padding in IE 8/9/10.\n//\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box; // 1\n padding: 0; // 2\n}\n\n//\n// Fix the cursor style for Chrome's increment/decrement buttons. For certain\n// `font-size` values of the `input`, it causes the cursor style of the\n// decrement button to change from `default` to `text`.\n//\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n//\n// 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n// 2. Address `box-sizing` set to `border-box` in Safari and Chrome.\n//\n\ninput[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n box-sizing: content-box; //2\n}\n\n//\n// Remove inner padding and search cancel button in Safari and Chrome on OS X.\n// Safari (but not Chrome) clips the cancel button when the search input has\n// padding (and `textfield` appearance).\n//\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// Define consistent border, margin, and padding.\n//\n\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\n\n//\n// 1. Correct `color` not being inherited in IE 8/9/10/11.\n// 2. Remove padding so people aren't caught out if they zero out fieldsets.\n//\n\nlegend {\n border: 0; // 1\n padding: 0; // 2\n}\n\n//\n// Remove default vertical scrollbar in IE 8/9/10/11.\n//\n\ntextarea {\n overflow: auto;\n}\n\n//\n// Don't inherit the `font-weight` (applied by a rule above).\n// NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n//\n\noptgroup {\n font-weight: bold;\n}\n\n// Tables\n// ==========================================================================\n\n//\n// Remove most spacing between table cells.\n//\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\ntd,\nth {\n padding: 0;\n}\n","/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n\n// ==========================================================================\n// Print styles.\n// Inlined to avoid the additional HTTP request: h5bp.com/r\n// ==========================================================================\n\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important; // Black prints faster: h5bp.com/s\n box-shadow: none !important;\n text-shadow: none !important;\n }\n\n a,\n a:visited {\n text-decoration: underline;\n }\n\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n\n // Don't show links that are fragment identifiers,\n // or use the `javascript:` pseudo protocol\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n\n thead {\n display: table-header-group; // h5bp.com/t\n }\n\n tr,\n img {\n page-break-inside: avoid;\n }\n\n img {\n max-width: 100% !important;\n }\n\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n\n h2,\n h3 {\n page-break-after: avoid;\n }\n\n // Bootstrap specific changes start\n\n // Bootstrap components\n .navbar {\n display: none;\n }\n .btn,\n .dropup > .btn {\n > .caret {\n border-top-color: #000 !important;\n }\n }\n .label {\n border: 1px solid #000;\n }\n\n .table {\n border-collapse: collapse !important;\n\n td,\n th {\n background-color: #fff !important;\n }\n }\n .table-bordered {\n th,\n td {\n border: 1px solid #ddd !important;\n }\n }\n\n // Bootstrap specific changes end\n}\n","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\nmark {\n background: #ff0;\n color: #000;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsup {\n top: -0.5em;\n}\nsub {\n bottom: -0.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n box-sizing: content-box;\n height: 0;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit;\n font: inherit;\n margin: 0;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: textfield;\n box-sizing: content-box;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\nlegend {\n border: 0;\n padding: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important;\n box-shadow: none !important;\n text-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all 0.2s ease-in-out;\n -o-transition: all 0.2s ease-in-out;\n transition: all 0.2s ease-in-out;\n display: inline-block;\n max-width: 100%;\n height: auto;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eeeeee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n background-color: #fcf8e3;\n padding: .2em;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eeeeee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n list-style: none;\n margin-left: -5px;\n}\n.list-inline > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n clear: left;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eeeeee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid #eeeeee;\n border-left: 0;\n text-align: right;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n word-break: break-all;\n word-wrap: break-word;\n color: #333333;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n.row {\n margin-left: -15px;\n margin-right: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0%;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0%;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-column;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-cell;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n min-width: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n border: 0;\n background-color: transparent;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eeeeee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n min-height: 34px;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-left: 0;\n padding-right: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n border-color: #3c763d;\n background-color: #dff0d8;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n border-color: #8a6d3b;\n background-color: #fcf8e3;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n border-color: #a94442;\n background-color: #f2dede;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: 7px;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-left: -15px;\n margin-right: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: 7px;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n margin-bottom: 0;\n font-weight: normal;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none;\n border: 1px solid transparent;\n white-space: nowrap;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n border-radius: 4px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n outline: 0;\n background-image: none;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n opacity: 0.65;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n color: #337ab7;\n font-weight: normal;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity 0.15s linear;\n -o-transition: opacity 0.15s linear;\n transition: opacity 0.15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-property: height, visibility;\n transition-property: height, visibility;\n -webkit-transition-duration: 0.35s;\n transition-duration: 0.35s;\n -webkit-transition-timing-function: ease;\n transition-timing-function: ease;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n list-style: none;\n font-size: 14px;\n text-align: left;\n background-color: #fff;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n background-clip: padding-box;\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n text-decoration: none;\n color: #262626;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n background-color: #337ab7;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n cursor: not-allowed;\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n left: auto;\n right: 0;\n}\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n content: \"\";\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n left: auto;\n right: 0;\n }\n .navbar-right .dropdown-menu-left {\n left: 0;\n right: auto;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555555;\n text-align: center;\n background-color: #eeeeee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n margin-bottom: 0;\n padding-left: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.nav > li.disabled > a {\n color: #777777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777777;\n text-decoration: none;\n background-color: transparent;\n cursor: not-allowed;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eeeeee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eeeeee #eeeeee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555555;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n cursor: default;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n overflow-x: visible;\n padding-right: 15px;\n padding-left: 15px;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);\n -webkit-overflow-scrolling: touch;\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-left: 0;\n padding-right: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n height: 50px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: 15px;\n padding: 9px 10px;\n margin-top: 8px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n margin-left: -15px;\n margin-right: -15px;\n padding: 10px 15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n margin-top: 8px;\n margin-bottom: 8px;\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-left: 15px;\n margin-right: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n background-color: #e7e7e7;\n color: #555;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n background-color: #080808;\n color: #fff;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n content: \"/\\00a0\";\n padding: 0 5px;\n color: #ccc;\n}\n.breadcrumb > .active {\n color: #777777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n line-height: 1.42857143;\n text-decoration: none;\n color: #337ab7;\n background-color: #fff;\n border: 1px solid #ddd;\n margin-left: -1px;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-bottom-left-radius: 4px;\n border-top-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-bottom-right-radius: 4px;\n border-top-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eeeeee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n cursor: default;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777777;\n background-color: #fff;\n border-color: #ddd;\n cursor: not-allowed;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-bottom-left-radius: 6px;\n border-top-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-bottom-right-radius: 6px;\n border-top-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-bottom-left-radius: 3px;\n border-top-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-bottom-right-radius: 3px;\n border-top-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n list-style: none;\n text-align: center;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777777;\n background-color: #fff;\n cursor: not-allowed;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n color: #fff;\n line-height: 1;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: #777777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eeeeee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n border-radius: 6px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-left: 60px;\n padding-right: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border 0.2s ease-in-out;\n -o-transition: border 0.2s ease-in-out;\n transition: border 0.2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-left: auto;\n margin-right: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n background-color: #dff0d8;\n border-color: #d6e9c6;\n color: #3c763d;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n background-color: #d9edf7;\n border-color: #bce8f1;\n color: #31708f;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n background-color: #fcf8e3;\n border-color: #faebcc;\n color: #8a6d3b;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n background-color: #f2dede;\n border-color: #ebccd1;\n color: #a94442;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n overflow: hidden;\n height: 20px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n -webkit-transition: width 0.6s ease;\n -o-transition: width 0.6s ease;\n transition: width 0.6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n margin-bottom: 20px;\n padding-left: 0;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n text-decoration: none;\n color: #555;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n background-color: #eeeeee;\n color: #777777;\n cursor: not-allowed;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-left: 15px;\n padding-right: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-left-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n border: 0;\n margin-bottom: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: 0.2;\n filter: alpha(opacity=20);\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\nbutton.close {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n -webkit-transition: -webkit-transform 0.3s ease-out;\n -moz-transition: -moz-transform 0.3s ease-out;\n -o-transition: -o-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n background-clip: padding-box;\n outline: 0;\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.modal-backdrop.in {\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 12px;\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.tooltip.in {\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.tooltip.top {\n margin-top: -3px;\n padding: 5px 0;\n}\n.tooltip.right {\n margin-left: 3px;\n padding: 0 5px;\n}\n.tooltip.bottom {\n margin-top: 3px;\n padding: 5px 0;\n}\n.tooltip.left {\n margin-left: -3px;\n padding: 0 5px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n bottom: 0;\n right: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 14px;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n margin: 0;\n padding: 8px 14px;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n border-width: 10px;\n content: \"\";\n}\n.popover.top > .arrow {\n left: 50%;\n margin-left: -11px;\n border-bottom-width: 0;\n border-top-color: #999999;\n border-top-color: rgba(0, 0, 0, 0.25);\n bottom: -11px;\n}\n.popover.top > .arrow:after {\n content: \" \";\n bottom: 1px;\n margin-left: -10px;\n border-bottom-width: 0;\n border-top-color: #fff;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-left-width: 0;\n border-right-color: #999999;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.popover.right > .arrow:after {\n content: \" \";\n left: 1px;\n bottom: -10px;\n border-left-width: 0;\n border-right-color: #fff;\n}\n.popover.bottom > .arrow {\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999999;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n top: -11px;\n}\n.popover.bottom > .arrow:after {\n content: \" \";\n top: 1px;\n margin-left: -10px;\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999999;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.popover.left > .arrow:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: #fff;\n bottom: -10px;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n}\n.carousel-inner > .item {\n display: none;\n position: relative;\n -webkit-transition: 0.6s ease-in-out left;\n -o-transition: 0.6s ease-in-out left;\n transition: 0.6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform 0.6s ease-in-out;\n -moz-transition: -moz-transform 0.6s ease-in-out;\n -o-transition: -o-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n -webkit-backface-visibility: hidden;\n -moz-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n -moz-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n left: 0;\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 15%;\n opacity: 0.5;\n filter: alpha(opacity=50);\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n}\n.carousel-control.right {\n left: auto;\n right: 0;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n}\n.carousel-control:hover,\n.carousel-control:focus {\n outline: 0;\n color: #fff;\n text-decoration: none;\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid #fff;\n border-radius: 10px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-indicators .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n content: \" \";\n display: table;\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -webkit-text-size-adjust: 100%;\n -ms-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n margin: .67em 0;\n font-size: 2em;\n}\nmark {\n color: #000;\n background: #ff0;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n position: relative;\n font-size: 75%;\n line-height: 0;\n vertical-align: baseline;\n}\nsup {\n top: -.5em;\n}\nsub {\n bottom: -.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n height: 0;\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n margin: 0;\n font: inherit;\n color: inherit;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n padding: 0;\n border: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: content-box;\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n -webkit-appearance: textfield;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n padding: .35em .625em .75em;\n margin: 0 2px;\n border: 1px solid #c0c0c0;\n}\nlegend {\n padding: 0;\n border: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-spacing: 0;\n border-collapse: collapse;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n color: #000 !important;\n text-shadow: none !important;\n background: transparent !important;\n -webkit-box-shadow: none !important;\n box-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n display: inline-block;\n max-width: 100%;\n height: auto;\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all .2s ease-in-out;\n -o-transition: all .2s ease-in-out;\n transition: all .2s ease-in-out;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n padding: .2em;\n background-color: #fcf8e3;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n margin-left: -5px;\n list-style: none;\n}\n.list-inline > li {\n display: inline-block;\n padding-right: 5px;\n padding-left: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n overflow: hidden;\n clear: left;\n text-align: right;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n text-align: right;\n border-right: 5px solid #eee;\n border-left: 0;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n color: #333;\n word-break: break-all;\n word-wrap: break-word;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n padding-right: 15px;\n padding-left: 15px;\n margin-right: auto;\n margin-left: auto;\n}\n.row {\n margin-right: -15px;\n margin-left: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-right: 15px;\n padding-left: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n display: table-column;\n float: none;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n display: table-cell;\n float: none;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n min-height: .01%;\n overflow-x: auto;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n background-color: transparent;\n border: 0;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-top: 4px \\9;\n margin-left: -20px;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n vertical-align: middle;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n min-height: 34px;\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-right: 0;\n padding-left: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #3c763d;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #8a6d3b;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n background-color: #f2dede;\n border-color: #a94442;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n padding-top: 7px;\n margin-top: 0;\n margin-bottom: 0;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n padding-top: 7px;\n margin-bottom: 0;\n text-align: right;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n padding: 6px 12px;\n margin-bottom: 0;\n font-size: 14px;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n cursor: pointer;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n outline: 0;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n opacity: .65;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n font-weight: normal;\n color: #337ab7;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity .15s linear;\n -o-transition: opacity .15s linear;\n transition: opacity .15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-timing-function: ease;\n -o-transition-timing-function: ease;\n transition-timing-function: ease;\n -webkit-transition-duration: .35s;\n -o-transition-duration: .35s;\n transition-duration: .35s;\n -webkit-transition-property: height, visibility;\n -o-transition-property: height, visibility;\n transition-property: height, visibility;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n font-size: 14px;\n text-align: left;\n list-style: none;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, .175);\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n color: #262626;\n text-decoration: none;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n background-color: #337ab7;\n outline: 0;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu-left {\n right: auto;\n left: 0;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n content: \"\";\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n right: 0;\n left: auto;\n }\n .navbar-right .dropdown-menu-left {\n right: auto;\n left: 0;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-right: 8px;\n padding-left: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-right: 12px;\n padding-left: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n display: table-cell;\n float: none;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-right: 0;\n padding-left: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555;\n text-align: center;\n background-color: #eee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n padding-left: 0;\n margin-bottom: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.nav > li.disabled > a {\n color: #777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777;\n text-decoration: none;\n cursor: not-allowed;\n background-color: transparent;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eee #eee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555;\n cursor: default;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n margin-bottom: 5px;\n text-align: center;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n padding-right: 15px;\n padding-left: 15px;\n overflow-x: visible;\n -webkit-overflow-scrolling: touch;\n border-top: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1);\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-right: 0;\n padding-left: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n height: 50px;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n padding: 9px 10px;\n margin-top: 8px;\n margin-right: 15px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n padding: 10px 15px;\n margin-top: 8px;\n margin-right: -15px;\n margin-bottom: 8px;\n margin-left: -15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1);\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n padding-top: 0;\n padding-bottom: 0;\n margin-right: 0;\n margin-left: 0;\n border: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-right: 15px;\n margin-left: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n color: #fff;\n background-color: #080808;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n padding: 0 5px;\n color: #ccc;\n content: \"/\\00a0\";\n}\n.breadcrumb > .active {\n color: #777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n margin-left: -1px;\n line-height: 1.42857143;\n color: #337ab7;\n text-decoration: none;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-top-left-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-top-right-radius: 4px;\n border-bottom-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n cursor: default;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n border-color: #ddd;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-top-left-radius: 6px;\n border-bottom-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-top-right-radius: 6px;\n border-bottom-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-top-left-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-top-right-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n text-align: center;\n list-style: none;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777;\n cursor: not-allowed;\n background-color: #fff;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: middle;\n background-color: #777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n padding-right: 15px;\n padding-left: 15px;\n border-radius: 6px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-right: 60px;\n padding-left: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border .2s ease-in-out;\n -o-transition: border .2s ease-in-out;\n transition: border .2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-right: auto;\n margin-left: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@-o-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n height: 20px;\n margin-bottom: 20px;\n overflow: hidden;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);\n}\n.progress-bar {\n float: left;\n width: 0;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15);\n -webkit-transition: width .6s ease;\n -o-transition: width .6s ease;\n transition: width .6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n -webkit-background-size: 40px 40px;\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n overflow: hidden;\n zoom: 1;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n padding-left: 0;\n margin-bottom: 20px;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-left-radius: 4px;\n border-top-right-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n color: #555;\n text-decoration: none;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n color: #777;\n cursor: not-allowed;\n background-color: #eee;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, .05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-right: 15px;\n padding-left: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n margin-bottom: 0;\n border: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, .15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n filter: alpha(opacity=20);\n opacity: .2;\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n filter: alpha(opacity=50);\n opacity: .5;\n}\nbutton.close {\n -webkit-appearance: none;\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n display: none;\n overflow: hidden;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transition: -webkit-transform .3s ease-out;\n -o-transition: -o-transform .3s ease-out;\n transition: transform .3s ease-out;\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n outline: 0;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, .5);\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n filter: alpha(opacity=0);\n opacity: 0;\n}\n.modal-backdrop.in {\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-bottom: 0;\n margin-left: 5px;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, .5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n filter: alpha(opacity=0);\n opacity: 0;\n\n line-break: auto;\n}\n.tooltip.in {\n filter: alpha(opacity=90);\n opacity: .9;\n}\n.tooltip.top {\n padding: 5px 0;\n margin-top: -3px;\n}\n.tooltip.right {\n padding: 0 5px;\n margin-left: 3px;\n}\n.tooltip.bottom {\n padding: 5px 0;\n margin-top: 3px;\n}\n.tooltip.left {\n padding: 0 5px;\n margin-left: -3px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n right: 5px;\n bottom: 0;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n font-style: normal;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n letter-spacing: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n white-space: normal;\n background-color: #fff;\n -webkit-background-clip: padding-box;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, .2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, .2);\n\n line-break: auto;\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n padding: 8px 14px;\n margin: 0;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n content: \"\";\n border-width: 10px;\n}\n.popover.top > .arrow {\n bottom: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-color: #999;\n border-top-color: rgba(0, 0, 0, .25);\n border-bottom-width: 0;\n}\n.popover.top > .arrow:after {\n bottom: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-color: #fff;\n border-bottom-width: 0;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-right-color: #999;\n border-right-color: rgba(0, 0, 0, .25);\n border-left-width: 0;\n}\n.popover.right > .arrow:after {\n bottom: -10px;\n left: 1px;\n content: \" \";\n border-right-color: #fff;\n border-left-width: 0;\n}\n.popover.bottom > .arrow {\n top: -11px;\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999;\n border-bottom-color: rgba(0, 0, 0, .25);\n}\n.popover.bottom > .arrow:after {\n top: 1px;\n margin-left: -10px;\n content: \" \";\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999;\n border-left-color: rgba(0, 0, 0, .25);\n}\n.popover.left > .arrow:after {\n right: 1px;\n bottom: -10px;\n content: \" \";\n border-right-width: 0;\n border-left-color: #fff;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n width: 100%;\n overflow: hidden;\n}\n.carousel-inner > .item {\n position: relative;\n display: none;\n -webkit-transition: .6s ease-in-out left;\n -o-transition: .6s ease-in-out left;\n transition: .6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform .6s ease-in-out;\n -o-transition: -o-transform .6s ease-in-out;\n transition: transform .6s ease-in-out;\n\n -webkit-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n left: 0;\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n left: 0;\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n left: 0;\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 15%;\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n background-color: rgba(0, 0, 0, 0);\n filter: alpha(opacity=50);\n opacity: .5;\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control.right {\n right: 0;\n left: auto;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5)));\n background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n background-repeat: repeat-x;\n}\n.carousel-control:hover,\n.carousel-control:focus {\n color: #fff;\n text-decoration: none;\n filter: alpha(opacity=90);\n outline: 0;\n opacity: .9;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n z-index: 5;\n display: inline-block;\n margin-top: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n font-family: serif;\n line-height: 1;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n padding-left: 0;\n margin-left: -30%;\n text-align: center;\n list-style: none;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n border: 1px solid #fff;\n border-radius: 10px;\n}\n.carousel-indicators .active {\n width: 12px;\n height: 12px;\n margin: 0;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n right: 15%;\n bottom: 20px;\n left: 15%;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, .6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n right: 20%;\n left: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n display: table;\n content: \" \";\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-right: auto;\n margin-left: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n// <a href=\"#\"><span class=\"glyphicon glyphicon-star\"></span> Star</a>\n\n// Import the fonts\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('@{icon-font-path}@{icon-font-name}.eot');\n src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'),\n url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'),\n url('@{icon-font-path}@{icon-font-name}.woff') format('woff'),\n url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'),\n url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg');\n}\n\n// Catchall baseclass\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk { &:before { content: \"\\002a\"; } }\n.glyphicon-plus { &:before { content: \"\\002b\"; } }\n.glyphicon-euro,\n.glyphicon-eur { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil { &:before { content: \"\\270f\"; } }\n.glyphicon-glass { &:before { content: \"\\e001\"; } }\n.glyphicon-music { &:before { content: \"\\e002\"; } }\n.glyphicon-search { &:before { content: \"\\e003\"; } }\n.glyphicon-heart { &:before { content: \"\\e005\"; } }\n.glyphicon-star { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty { &:before { content: \"\\e007\"; } }\n.glyphicon-user { &:before { content: \"\\e008\"; } }\n.glyphicon-film { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large { &:before { content: \"\\e010\"; } }\n.glyphicon-th { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list { &:before { content: \"\\e012\"; } }\n.glyphicon-ok { &:before { content: \"\\e013\"; } }\n.glyphicon-remove { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out { &:before { content: \"\\e016\"; } }\n.glyphicon-off { &:before { content: \"\\e017\"; } }\n.glyphicon-signal { &:before { content: \"\\e018\"; } }\n.glyphicon-cog { &:before { content: \"\\e019\"; } }\n.glyphicon-trash { &:before { content: \"\\e020\"; } }\n.glyphicon-home { &:before { content: \"\\e021\"; } }\n.glyphicon-file { &:before { content: \"\\e022\"; } }\n.glyphicon-time { &:before { content: \"\\e023\"; } }\n.glyphicon-road { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt { &:before { content: \"\\e025\"; } }\n.glyphicon-download { &:before { content: \"\\e026\"; } }\n.glyphicon-upload { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt { &:before { content: \"\\e032\"; } }\n.glyphicon-lock { &:before { content: \"\\e033\"; } }\n.glyphicon-flag { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode { &:before { content: \"\\e040\"; } }\n.glyphicon-tag { &:before { content: \"\\e041\"; } }\n.glyphicon-tags { &:before { content: \"\\e042\"; } }\n.glyphicon-book { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark { &:before { content: \"\\e044\"; } }\n.glyphicon-print { &:before { content: \"\\e045\"; } }\n.glyphicon-camera { &:before { content: \"\\e046\"; } }\n.glyphicon-font { &:before { content: \"\\e047\"; } }\n.glyphicon-bold { &:before { content: \"\\e048\"; } }\n.glyphicon-italic { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify { &:before { content: \"\\e055\"; } }\n.glyphicon-list { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video { &:before { content: \"\\e059\"; } }\n.glyphicon-picture { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust { &:before { content: \"\\e063\"; } }\n.glyphicon-tint { &:before { content: \"\\e064\"; } }\n.glyphicon-edit { &:before { content: \"\\e065\"; } }\n.glyphicon-share { &:before { content: \"\\e066\"; } }\n.glyphicon-check { &:before { content: \"\\e067\"; } }\n.glyphicon-move { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward { &:before { content: \"\\e070\"; } }\n.glyphicon-backward { &:before { content: \"\\e071\"; } }\n.glyphicon-play { &:before { content: \"\\e072\"; } }\n.glyphicon-pause { &:before { content: \"\\e073\"; } }\n.glyphicon-stop { &:before { content: \"\\e074\"; } }\n.glyphicon-forward { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward { &:before { content: \"\\e077\"; } }\n.glyphicon-eject { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign { &:before { content: \"\\e101\"; } }\n.glyphicon-gift { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf { &:before { content: \"\\e103\"; } }\n.glyphicon-fire { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign { &:before { content: \"\\e107\"; } }\n.glyphicon-plane { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar { &:before { content: \"\\e109\"; } }\n.glyphicon-random { &:before { content: \"\\e110\"; } }\n.glyphicon-comment { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn { &:before { content: \"\\e122\"; } }\n.glyphicon-bell { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down { &:before { content: \"\\e134\"; } }\n.glyphicon-globe { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks { &:before { content: \"\\e137\"; } }\n.glyphicon-filter { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty { &:before { content: \"\\e143\"; } }\n.glyphicon-link { &:before { content: \"\\e144\"; } }\n.glyphicon-phone { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin { &:before { content: \"\\e146\"; } }\n.glyphicon-usd { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp { &:before { content: \"\\e149\"; } }\n.glyphicon-sort { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked { &:before { content: \"\\e157\"; } }\n.glyphicon-expand { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in { &:before { content: \"\\e161\"; } }\n.glyphicon-flash { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window { &:before { content: \"\\e164\"; } }\n.glyphicon-record { &:before { content: \"\\e165\"; } }\n.glyphicon-save { &:before { content: \"\\e166\"; } }\n.glyphicon-open { &:before { content: \"\\e167\"; } }\n.glyphicon-saved { &:before { content: \"\\e168\"; } }\n.glyphicon-import { &:before { content: \"\\e169\"; } }\n.glyphicon-export { &:before { content: \"\\e170\"; } }\n.glyphicon-send { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery { &:before { content: \"\\e179\"; } }\n.glyphicon-header { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt { &:before { content: \"\\e183\"; } }\n.glyphicon-tower { &:before { content: \"\\e184\"; } }\n.glyphicon-stats { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1 { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1 { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1 { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous { &:before { content: \"\\e200\"; } }\n.glyphicon-cd { &:before { content: \"\\e201\"; } }\n.glyphicon-save-file { &:before { content: \"\\e202\"; } }\n.glyphicon-open-file { &:before { content: \"\\e203\"; } }\n.glyphicon-level-up { &:before { content: \"\\e204\"; } }\n.glyphicon-copy { &:before { content: \"\\e205\"; } }\n.glyphicon-paste { &:before { content: \"\\e206\"; } }\n// The following 2 Glyphicons are omitted for the time being because\n// they currently use Unicode codepoints that are outside the\n// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle\n// non-BMP codepoints in CSS string escapes, and thus can't display these two icons.\n// Notably, the bug affects some older versions of the Android Browser.\n// More info: https://github.com/twbs/bootstrap/issues/10106\n// .glyphicon-door { &:before { content: \"\\1f6aa\"; } }\n// .glyphicon-key { &:before { content: \"\\1f511\"; } }\n.glyphicon-alert { &:before { content: \"\\e209\"; } }\n.glyphicon-equalizer { &:before { content: \"\\e210\"; } }\n.glyphicon-king { &:before { content: \"\\e211\"; } }\n.glyphicon-queen { &:before { content: \"\\e212\"; } }\n.glyphicon-pawn { &:before { content: \"\\e213\"; } }\n.glyphicon-bishop { &:before { content: \"\\e214\"; } }\n.glyphicon-knight { &:before { content: \"\\e215\"; } }\n.glyphicon-baby-formula { &:before { content: \"\\e216\"; } }\n.glyphicon-tent { &:before { content: \"\\26fa\"; } }\n.glyphicon-blackboard { &:before { content: \"\\e218\"; } }\n.glyphicon-bed { &:before { content: \"\\e219\"; } }\n.glyphicon-apple { &:before { content: \"\\f8ff\"; } }\n.glyphicon-erase { &:before { content: \"\\e221\"; } }\n.glyphicon-hourglass { &:before { content: \"\\231b\"; } }\n.glyphicon-lamp { &:before { content: \"\\e223\"; } }\n.glyphicon-duplicate { &:before { content: \"\\e224\"; } }\n.glyphicon-piggy-bank { &:before { content: \"\\e225\"; } }\n.glyphicon-scissors { &:before { content: \"\\e226\"; } }\n.glyphicon-bitcoin { &:before { content: \"\\e227\"; } }\n.glyphicon-btc { &:before { content: \"\\e227\"; } }\n.glyphicon-xbt { &:before { content: \"\\e227\"; } }\n.glyphicon-yen { &:before { content: \"\\00a5\"; } }\n.glyphicon-jpy { &:before { content: \"\\00a5\"; } }\n.glyphicon-ruble { &:before { content: \"\\20bd\"; } }\n.glyphicon-rub { &:before { content: \"\\20bd\"; } }\n.glyphicon-scale { &:before { content: \"\\e230\"; } }\n.glyphicon-ice-lolly { &:before { content: \"\\e231\"; } }\n.glyphicon-ice-lolly-tasted { &:before { content: \"\\e232\"; } }\n.glyphicon-education { &:before { content: \"\\e233\"; } }\n.glyphicon-option-horizontal { &:before { content: \"\\e234\"; } }\n.glyphicon-option-vertical { &:before { content: \"\\e235\"; } }\n.glyphicon-menu-hamburger { &:before { content: \"\\e236\"; } }\n.glyphicon-modal-window { &:before { content: \"\\e237\"; } }\n.glyphicon-oil { &:before { content: \"\\e238\"; } }\n.glyphicon-grain { &:before { content: \"\\e239\"; } }\n.glyphicon-sunglasses { &:before { content: \"\\e240\"; } }\n.glyphicon-text-size { &:before { content: \"\\e241\"; } }\n.glyphicon-text-color { &:before { content: \"\\e242\"; } }\n.glyphicon-text-background { &:before { content: \"\\e243\"; } }\n.glyphicon-object-align-top { &:before { content: \"\\e244\"; } }\n.glyphicon-object-align-bottom { &:before { content: \"\\e245\"; } }\n.glyphicon-object-align-horizontal{ &:before { content: \"\\e246\"; } }\n.glyphicon-object-align-left { &:before { content: \"\\e247\"; } }\n.glyphicon-object-align-vertical { &:before { content: \"\\e248\"; } }\n.glyphicon-object-align-right { &:before { content: \"\\e249\"; } }\n.glyphicon-triangle-right { &:before { content: \"\\e250\"; } }\n.glyphicon-triangle-left { &:before { content: \"\\e251\"; } }\n.glyphicon-triangle-bottom { &:before { content: \"\\e252\"; } }\n.glyphicon-triangle-top { &:before { content: \"\\e253\"; } }\n.glyphicon-console { &:before { content: \"\\e254\"; } }\n.glyphicon-superscript { &:before { content: \"\\e255\"; } }\n.glyphicon-subscript { &:before { content: \"\\e256\"; } }\n.glyphicon-menu-left { &:before { content: \"\\e257\"; } }\n.glyphicon-menu-right { &:before { content: \"\\e258\"; } }\n.glyphicon-menu-down { &:before { content: \"\\e259\"; } }\n.glyphicon-menu-up { &:before { content: \"\\e260\"; } }\n","//\n// Scaffolding\n// --------------------------------------------------\n\n\n// Reset the box-sizing\n//\n// Heads up! This reset may cause conflicts with some third-party widgets.\n// For recommendations on resolving such conflicts, see\n// http://getbootstrap.com/getting-started/#third-box-sizing\n* {\n .box-sizing(border-box);\n}\n*:before,\n*:after {\n .box-sizing(border-box);\n}\n\n\n// Body reset\n\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n}\n\nbody {\n font-family: @font-family-base;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @text-color;\n background-color: @body-bg;\n}\n\n// Reset fonts for relevant elements\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\n\n// Links\n\na {\n color: @link-color;\n text-decoration: none;\n\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n }\n\n &:focus {\n .tab-focus();\n }\n}\n\n\n// Figures\n//\n// We reset this here because previously Normalize had no `figure` margins. This\n// ensures we don't break anyone's use of the element.\n\nfigure {\n margin: 0;\n}\n\n\n// Images\n\nimg {\n vertical-align: middle;\n}\n\n// Responsive images (ensure images don't scale beyond their parents)\n.img-responsive {\n .img-responsive();\n}\n\n// Rounded corners\n.img-rounded {\n border-radius: @border-radius-large;\n}\n\n// Image thumbnails\n//\n// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`.\n.img-thumbnail {\n padding: @thumbnail-padding;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(all .2s ease-in-out);\n\n // Keep them at most 100% wide\n .img-responsive(inline-block);\n}\n\n// Perfect circle\n.img-circle {\n border-radius: 50%; // set radius in percents\n}\n\n\n// Horizontal rules\n\nhr {\n margin-top: @line-height-computed;\n margin-bottom: @line-height-computed;\n border: 0;\n border-top: 1px solid @hr-border;\n}\n\n\n// Only display content to screen readers\n//\n// See: http://a11yproject.com/posts/how-to-hide-content\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0,0,0,0);\n border: 0;\n}\n\n// Use in conjunction with .sr-only to only display content when it's focused.\n// Useful for \"Skip to main content\" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n// Credit: HTML5 Boilerplate\n\n.sr-only-focusable {\n &:active,\n &:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n }\n}\n\n\n// iOS \"clickable elements\" fix for role=\"button\"\n//\n// Fixes \"clickability\" issue (and more generally, the firing of events such as focus as well)\n// for traditionally non-focusable elements with role=\"button\"\n// see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n\n[role=\"button\"] {\n cursor: pointer;\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// WebKit-style focus\n\n.tab-focus() {\n // WebKit-specific. Other browsers will keep their default outline style.\n // (Initially tried to also force default via `outline: initial`,\n // but that seems to erroneously remove the outline in Firefox altogether.)\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size. Note that the\n// spelling of `min--moz-device-pixel-ratio` is intentional.\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n font-family: @headings-font-family;\n font-weight: @headings-font-weight;\n line-height: @headings-line-height;\n color: @headings-color;\n\n small,\n .small {\n font-weight: normal;\n line-height: 1;\n color: @headings-small-color;\n }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n margin-top: @line-height-computed;\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 65%;\n }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n margin-top: (@line-height-computed / 2);\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 75%;\n }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n margin-bottom: @line-height-computed;\n font-size: floor((@font-size-base * 1.15));\n font-weight: 300;\n line-height: 1.4;\n\n @media (min-width: @screen-sm-min) {\n font-size: (@font-size-base * 1.5);\n }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: (12px small font / 14px base font) * 100% = about 85%\nsmall,\n.small {\n font-size: floor((100% * @font-size-small / @font-size-base));\n}\n\nmark,\n.mark {\n background-color: @state-warning-bg;\n padding: .2em;\n}\n\n// Alignment\n.text-left { text-align: left; }\n.text-right { text-align: right; }\n.text-center { text-align: center; }\n.text-justify { text-align: justify; }\n.text-nowrap { white-space: nowrap; }\n\n// Transformation\n.text-lowercase { text-transform: lowercase; }\n.text-uppercase { text-transform: uppercase; }\n.text-capitalize { text-transform: capitalize; }\n\n// Contextual colors\n.text-muted {\n color: @text-muted;\n}\n.text-primary {\n .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n // Given the contrast here, this is the only class to have its color inverted\n // automatically.\n color: #fff;\n .bg-variant(@brand-primary);\n}\n.bg-success {\n .bg-variant(@state-success-bg);\n}\n.bg-info {\n .bg-variant(@state-info-bg);\n}\n.bg-warning {\n .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n padding-bottom: ((@line-height-computed / 2) - 1);\n margin: (@line-height-computed * 2) 0 @line-height-computed;\n border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// -------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n margin-top: 0;\n margin-bottom: (@line-height-computed / 2);\n ul,\n ol {\n margin-bottom: 0;\n }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n .list-unstyled();\n margin-left: -5px;\n\n > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n }\n}\n\n// Description Lists\ndl {\n margin-top: 0; // Remove browser default\n margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n line-height: @line-height-base;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n.dl-horizontal {\n dd {\n &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n }\n\n @media (min-width: @dl-horizontal-breakpoint) {\n dt {\n float: left;\n width: (@dl-horizontal-offset - 20);\n clear: left;\n text-align: right;\n .text-overflow();\n }\n dd {\n margin-left: @dl-horizontal-offset;\n }\n }\n}\n\n\n// Misc\n// -------------------------\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n font-size: 90%;\n .text-uppercase();\n}\n\n// Blockquotes\nblockquote {\n padding: (@line-height-computed / 2) @line-height-computed;\n margin: 0 0 @line-height-computed;\n font-size: @blockquote-font-size;\n border-left: 5px solid @blockquote-border-color;\n\n p,\n ul,\n ol {\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n // Note: Deprecated small and .small as of v3.1.0\n // Context: https://github.com/twbs/bootstrap/issues/11660\n footer,\n small,\n .small {\n display: block;\n font-size: 80%; // back to default font-size\n line-height: @line-height-base;\n color: @blockquote-small-color;\n\n &:before {\n content: '\\2014 \\00A0'; // em dash, nbsp\n }\n }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid @blockquote-border-color;\n border-left: 0;\n text-align: right;\n\n // Account for citation\n footer,\n small,\n .small {\n &:before { content: ''; }\n &:after {\n content: '\\00A0 \\2014'; // nbsp, em dash\n }\n }\n}\n\n// Addresses\naddress {\n margin-bottom: @line-height-computed;\n font-style: normal;\n line-height: @line-height-base;\n}\n","// Typography\n\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover,\n a&:focus {\n color: darken(@color, 10%);\n }\n}\n","// Contextual backgrounds\n\n.bg-variant(@color) {\n background-color: @color;\n a&:hover,\n a&:focus {\n background-color: darken(@color, 10%);\n }\n}\n","// Text overflow\n// Requires inline-block or block for proper styling\n\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: @code-color;\n background-color: @code-bg;\n border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: @kbd-color;\n background-color: @kbd-bg;\n border-radius: @border-radius-small;\n box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n }\n}\n\n// Blocks of code\npre {\n display: block;\n padding: ((@line-height-computed - 1) / 2);\n margin: 0 0 (@line-height-computed / 2);\n font-size: (@font-size-base - 1); // 14px to 13px\n line-height: @line-height-base;\n word-break: break-all;\n word-wrap: break-word;\n color: @pre-color;\n background-color: @pre-bg;\n border: 1px solid @pre-border-color;\n border-radius: @border-radius-base;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: @pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n .container-fixed();\n\n @media (min-width: @screen-sm-min) {\n width: @container-sm;\n }\n @media (min-width: @screen-md-min) {\n width: @container-md;\n }\n @media (min-width: @screen-lg-min) {\n width: @container-lg;\n }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n .make-grid(lg);\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n// Centered container element\n.container-fixed(@gutter: @grid-gutter-width) {\n margin-right: auto;\n margin-left: auto;\n padding-left: floor((@gutter / 2));\n padding-right: ceil((@gutter / 2));\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: ceil((@gutter / -2));\n margin-right: floor((@gutter / -2));\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n margin-left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-push(@columns) {\n left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-pull(@columns) {\n right: percentage((@columns / @grid-columns));\n}\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: ceil((@grid-gutter-width / 2));\n padding-right: floor((@grid-gutter-width / 2));\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) {\n .col-@{class}-push-0 {\n left: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) {\n .col-@{class}-pull-0 {\n right: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n background-color: @table-bg;\n}\ncaption {\n padding-top: @table-cell-padding;\n padding-bottom: @table-cell-padding;\n color: @text-muted;\n text-align: left;\n}\nth {\n text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: @line-height-computed;\n // Cells\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-cell-padding;\n line-height: @line-height-base;\n vertical-align: top;\n border-top: 1px solid @table-border-color;\n }\n }\n }\n // Bottom align for column headings\n > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid @table-border-color;\n }\n // Remove top border from thead by default\n > caption + thead,\n > colgroup + thead,\n > thead:first-child {\n > tr:first-child {\n > th,\n > td {\n border-top: 0;\n }\n }\n }\n // Account for multiple tbody instances\n > tbody + tbody {\n border-top: 2px solid @table-border-color;\n }\n\n // Nesting\n .table {\n background-color: @body-bg;\n }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-condensed-cell-padding;\n }\n }\n }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n border: 1px solid @table-border-color;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n border: 1px solid @table-border-color;\n }\n }\n }\n > thead > tr {\n > th,\n > td {\n border-bottom-width: 2px;\n }\n }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(odd) {\n background-color: @table-bg-accent;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n background-color: @table-bg-hover;\n }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-column;\n}\ntable {\n td,\n th {\n &[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-cell;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837)\n\n @media screen and (max-width: @screen-xs-max) {\n width: 100%;\n margin-bottom: (@line-height-computed * 0.75);\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid @table-border-color;\n\n // Tighten up spacing\n > .table {\n margin-bottom: 0;\n\n // Ensure the content doesn't wrap\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n white-space: nowrap;\n }\n }\n }\n }\n\n // Special overrides for the bordered tables\n > .table-bordered {\n border: 0;\n\n // Nuke the appropriate borders so that the parent can handle them\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n\n // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n // chances are there will be only one `tr` in a `thead` and that would\n // remove the border altogether.\n > tbody,\n > tfoot {\n > tr:last-child {\n > th,\n > td {\n border-bottom: 0;\n }\n }\n }\n\n }\n }\n}\n","// Tables\n\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &:hover > .@{state},\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n // Chrome and Firefox set a `min-width: min-content;` on fieldsets,\n // so we reset that to ensure it behaves more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359.\n min-width: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: @line-height-computed;\n font-size: (@font-size-base * 1.5);\n line-height: inherit;\n color: @legend-color;\n border: 0;\n border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n display: inline-block;\n max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141)\n margin-bottom: 5px;\n font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9; // IE8-9\n line-height: normal;\n}\n\ninput[type=\"file\"] {\n display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n .tab-focus();\n}\n\n// Adjust output element\noutput {\n display: block;\n padding-top: (@padding-base-vertical + 1);\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n display: block;\n width: 100%;\n height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n background-color: @input-bg;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid @input-border;\n border-radius: @input-border-radius; // Note: This has no effect on <select>s in some browsers, due to the limited stylability of <select>s in CSS.\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n // Customize the `:focus` state to imitate native WebKit styles.\n .form-control-focus();\n\n // Placeholder\n .placeholder();\n\n // Unstyle the caret on `<select>`s in IE10+.\n &::-ms-expand {\n border: 0;\n background-color: transparent;\n }\n\n // Disabled and read-only inputs\n //\n // HTML5 says that controls under a fieldset > legend:first-child won't be\n // disabled if the fieldset is disabled. Due to implementation difficulty, we\n // don't honor that edge case; we style them as disabled anyway.\n &[disabled],\n &[readonly],\n fieldset[disabled] & {\n background-color: @input-bg-disabled;\n opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655\n }\n\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n }\n\n // Reset height for `textarea`s\n textarea& {\n height: auto;\n }\n}\n\n\n// Search inputs in iOS\n//\n// This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n\n\n// Special styles for iOS temporal inputs\n//\n// In Mobile Safari, setting `display: block` on temporal inputs causes the\n// text within the input to become vertically misaligned. As a workaround, we\n// set a pixel line-height that matches the given height of the input, but only\n// for Safari. See https://bugs.webkit.org/show_bug.cgi?id=139848\n//\n// Note that as of 9.3, iOS doesn't support `week`.\n\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"],\n input[type=\"time\"],\n input[type=\"datetime-local\"],\n input[type=\"month\"] {\n &.form-control {\n line-height: @input-height-base;\n }\n\n &.input-sm,\n .input-group-sm & {\n line-height: @input-height-small;\n }\n\n &.input-lg,\n .input-group-lg & {\n line-height: @input-height-large;\n }\n }\n}\n\n\n// Form groups\n//\n// Designed to help with the organization and spacing of vertical forms. For\n// horizontal forms, use the predefined grid classes.\n\n.form-group {\n margin-bottom: @form-group-margin-bottom;\n}\n\n\n// Checkboxes and radios\n//\n// Indent the labels to position radios/checkboxes as hanging controls.\n\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n\n label {\n min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n }\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing\n}\n\n// Radios and checkboxes on same line\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px; // space out consecutive inline controls\n}\n\n// Apply same disabled cursor tweak as for inputs\n// Some special care is needed because <label>s don't inherit their parent's `cursor`.\n//\n// Note: Neither radios nor checkboxes can be readonly.\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n &[disabled],\n &.disabled,\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n }\n}\n// These classes are used directly on <label>s\n.radio-inline,\n.checkbox-inline {\n &.disabled,\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n }\n}\n// These classes are used on elements with <label> descendants\n.radio,\n.checkbox {\n &.disabled,\n fieldset[disabled] & {\n label {\n cursor: @cursor-disabled;\n }\n }\n}\n\n\n// Static form control text\n//\n// Apply class to a `p` element to make any string of text align with labels in\n// a horizontal form layout.\n\n.form-control-static {\n // Size it appropriately next to real form controls\n padding-top: (@padding-base-vertical + 1);\n padding-bottom: (@padding-base-vertical + 1);\n // Remove default margin from `p`\n margin-bottom: 0;\n min-height: (@line-height-computed + @font-size-base);\n\n &.input-lg,\n &.input-sm {\n padding-left: 0;\n padding-right: 0;\n }\n}\n\n\n// Form control sizing\n//\n// Build on `.form-control` with modifier classes to decrease or increase the\n// height and font-size of form controls.\n//\n// The `.form-group-* form-control` variations are sadly duplicated to avoid the\n// issue documented in https://github.com/twbs/bootstrap/issues/15074.\n\n.input-sm {\n .input-size(@input-height-small; @padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @input-border-radius-small);\n}\n.form-group-sm {\n .form-control {\n height: @input-height-small;\n padding: @padding-small-vertical @padding-small-horizontal;\n font-size: @font-size-small;\n line-height: @line-height-small;\n border-radius: @input-border-radius-small;\n }\n select.form-control {\n height: @input-height-small;\n line-height: @input-height-small;\n }\n textarea.form-control,\n select[multiple].form-control {\n height: auto;\n }\n .form-control-static {\n height: @input-height-small;\n min-height: (@line-height-computed + @font-size-small);\n padding: (@padding-small-vertical + 1) @padding-small-horizontal;\n font-size: @font-size-small;\n line-height: @line-height-small;\n }\n}\n\n.input-lg {\n .input-size(@input-height-large; @padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @input-border-radius-large);\n}\n.form-group-lg {\n .form-control {\n height: @input-height-large;\n padding: @padding-large-vertical @padding-large-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-large;\n border-radius: @input-border-radius-large;\n }\n select.form-control {\n height: @input-height-large;\n line-height: @input-height-large;\n }\n textarea.form-control,\n select[multiple].form-control {\n height: auto;\n }\n .form-control-static {\n height: @input-height-large;\n min-height: (@line-height-computed + @font-size-large);\n padding: (@padding-large-vertical + 1) @padding-large-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-large;\n }\n}\n\n\n// Form control feedback states\n//\n// Apply contextual and semantic states to individual form controls.\n\n.has-feedback {\n // Enable absolute positioning\n position: relative;\n\n // Ensure icons don't overlap text\n .form-control {\n padding-right: (@input-height-base * 1.25);\n }\n}\n// Feedback icon (requires .glyphicon classes)\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2; // Ensure icon is above input groups\n display: block;\n width: @input-height-base;\n height: @input-height-base;\n line-height: @input-height-base;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: @input-height-large;\n height: @input-height-large;\n line-height: @input-height-large;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: @input-height-small;\n height: @input-height-small;\n line-height: @input-height-small;\n}\n\n// Feedback states\n.has-success {\n .form-control-validation(@state-success-text; @state-success-text; @state-success-bg);\n}\n.has-warning {\n .form-control-validation(@state-warning-text; @state-warning-text; @state-warning-bg);\n}\n.has-error {\n .form-control-validation(@state-danger-text; @state-danger-text; @state-danger-bg);\n}\n\n// Reposition feedback icon if input has visible label above\n.has-feedback label {\n\n & ~ .form-control-feedback {\n top: (@line-height-computed + 5); // Height of the `label` and its margin\n }\n &.sr-only ~ .form-control-feedback {\n top: 0;\n }\n}\n\n\n// Help text\n//\n// Apply to any element you wish to create light text for placement immediately\n// below a form control. Use for general help, formatting, or instructional text.\n\n.help-block {\n display: block; // account for any element using help-block\n margin-top: 5px;\n margin-bottom: 10px;\n color: lighten(@text-color, 25%); // lighten the text some for contrast\n}\n\n\n// Inline forms\n//\n// Make forms appear inline(-block) by adding the `.form-inline` class. Inline\n// forms begin stacked on extra small (mobile) devices and then go inline when\n// viewports reach <768px.\n//\n// Requires wrapping inputs and labels with `.form-group` for proper display of\n// default HTML form controls and our custom form controls (e.g., input groups).\n//\n// Heads up! This is mixin-ed into `.navbar-form` in navbars.less.\n\n.form-inline {\n\n // Kick in the inline\n @media (min-width: @screen-sm-min) {\n // Inline-block all the things for \"inline\"\n .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n\n // In navbar-form, allow folks to *not* use `.form-group`\n .form-control {\n display: inline-block;\n width: auto; // Prevent labels from stacking above inputs in `.form-group`\n vertical-align: middle;\n }\n\n // Make static controls behave like regular ones\n .form-control-static {\n display: inline-block;\n }\n\n .input-group {\n display: inline-table;\n vertical-align: middle;\n\n .input-group-addon,\n .input-group-btn,\n .form-control {\n width: auto;\n }\n }\n\n // Input groups need that 100% width though\n .input-group > .form-control {\n width: 100%;\n }\n\n .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n\n // Remove default margin on radios/checkboxes that were used for stacking, and\n // then undo the floating of radios and checkboxes to match.\n .radio,\n .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n\n label {\n padding-left: 0;\n }\n }\n .radio input[type=\"radio\"],\n .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n\n // Re-override the feedback icon.\n .has-feedback .form-control-feedback {\n top: 0;\n }\n }\n}\n\n\n// Horizontal forms\n//\n// Horizontal forms are built on grid classes and allow you to create forms with\n// labels on the left and inputs on the right.\n\n.form-horizontal {\n\n // Consistent vertical alignment of radios and checkboxes\n //\n // Labels also get some reset styles, but that is scoped to a media query below.\n .radio,\n .checkbox,\n .radio-inline,\n .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: (@padding-base-vertical + 1); // Default padding plus a border\n }\n // Account for padding we're adding to ensure the alignment and of help text\n // and other content below items\n .radio,\n .checkbox {\n min-height: (@line-height-computed + (@padding-base-vertical + 1));\n }\n\n // Make form groups behave like rows\n .form-group {\n .make-row();\n }\n\n // Reset spacing and right align labels, but scope to media queries so that\n // labels on narrow viewports stack the same as a default form example.\n @media (min-width: @screen-sm-min) {\n .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: (@padding-base-vertical + 1); // Default padding plus a border\n }\n }\n\n // Validation states\n //\n // Reposition the icon because it's now within a grid column and columns have\n // `position: relative;` on them. Also accounts for the grid gutter padding.\n .has-feedback .form-control-feedback {\n right: floor((@grid-gutter-width / 2));\n }\n\n // Form group sizes\n //\n // Quick utility class for applying `.input-lg` and `.input-sm` styles to the\n // inputs and labels within a `.form-group`.\n .form-group-lg {\n @media (min-width: @screen-sm-min) {\n .control-label {\n padding-top: (@padding-large-vertical + 1);\n font-size: @font-size-large;\n }\n }\n }\n .form-group-sm {\n @media (min-width: @screen-sm-min) {\n .control-label {\n padding-top: (@padding-small-vertical + 1);\n font-size: @font-size-small;\n }\n }\n }\n}\n","// Form validation states\n//\n// Used in forms.less to generate the form validation CSS for warnings, errors,\n// and successes.\n\n.form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) {\n // Color the label and help text\n .help-block,\n .control-label,\n .radio,\n .checkbox,\n .radio-inline,\n .checkbox-inline,\n &.radio label,\n &.checkbox label,\n &.radio-inline label,\n &.checkbox-inline label {\n color: @text-color;\n }\n // Set the border and box shadow on specific inputs to match\n .form-control {\n border-color: @border-color;\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work\n &:focus {\n border-color: darken(@border-color, 10%);\n @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%);\n .box-shadow(@shadow);\n }\n }\n // Set validation states also for addons\n .input-group-addon {\n color: @text-color;\n border-color: @border-color;\n background-color: @background-color;\n }\n // Optional feedback icon\n .form-control-feedback {\n color: @text-color;\n }\n}\n\n\n// Form control focus state\n//\n// Generate a customized focus state and for any input with the specified color,\n// which defaults to the `@input-border-focus` variable.\n//\n// We highly encourage you to not customize the default value, but instead use\n// this to tweak colors on an as-needed basis. This aesthetic change is based on\n// WebKit's default styles, but applicable to a wider range of browsers. Its\n// usability and accessibility should be taken into account with any change.\n//\n// Example usage: change the default blue border and shadow to white for better\n// contrast against a dark gray background.\n.form-control-focus(@color: @input-border-focus) {\n @color-rgba: rgba(red(@color), green(@color), blue(@color), .6);\n &:focus {\n border-color: @color;\n outline: 0;\n .box-shadow(~\"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}\");\n }\n}\n\n// Form control sizing\n//\n// Relative text size, padding, and border-radii changes for form controls. For\n// horizontal sizing, wrap controls in the predefined grid classes. `<select>`\n// element gets special love because it's special, and that's a fact!\n.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n height: @input-height;\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n\n select& {\n height: @input-height;\n line-height: @input-height;\n }\n\n textarea&,\n select[multiple]& {\n height: auto;\n }\n}\n","//\n// Buttons\n// --------------------------------------------------\n\n\n// Base styles\n// --------------------------------------------------\n\n.btn {\n display: inline-block;\n margin-bottom: 0; // For input.btn\n font-weight: @btn-font-weight;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n white-space: nowrap;\n .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @btn-border-radius-base);\n .user-select(none);\n\n &,\n &:active,\n &.active {\n &:focus,\n &.focus {\n .tab-focus();\n }\n }\n\n &:hover,\n &:focus,\n &.focus {\n color: @btn-default-color;\n text-decoration: none;\n }\n\n &:active,\n &.active {\n outline: 0;\n background-image: none;\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n .opacity(.65);\n .box-shadow(none);\n }\n\n a& {\n &.disabled,\n fieldset[disabled] & {\n pointer-events: none; // Future-proof disabling of clicks on `<a>` elements\n }\n }\n}\n\n\n// Alternate buttons\n// --------------------------------------------------\n\n.btn-default {\n .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border);\n}\n.btn-primary {\n .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border);\n}\n// Success appears as green\n.btn-success {\n .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border);\n}\n// Info appears as blue-green\n.btn-info {\n .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border);\n}\n// Warning appears as orange\n.btn-warning {\n .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border);\n}\n// Danger and error appear as red\n.btn-danger {\n .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border);\n}\n\n\n// Link buttons\n// -------------------------\n\n// Make a button look and behave like a link\n.btn-link {\n color: @link-color;\n font-weight: normal;\n border-radius: 0;\n\n &,\n &:active,\n &.active,\n &[disabled],\n fieldset[disabled] & {\n background-color: transparent;\n .box-shadow(none);\n }\n &,\n &:hover,\n &:focus,\n &:active {\n border-color: transparent;\n }\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n background-color: transparent;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @btn-link-disabled-color;\n text-decoration: none;\n }\n }\n}\n\n\n// Button Sizes\n// --------------------------------------------------\n\n.btn-lg {\n // line-height: ensure even-numbered height of button next to large input\n .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @btn-border-radius-large);\n}\n.btn-sm {\n // line-height: ensure proper height of button next to small input\n .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n.btn-xs {\n .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n\n\n// Block button\n// --------------------------------------------------\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n// Vertically space out multiple block buttons\n.btn-block + .btn-block {\n margin-top: 5px;\n}\n\n// Specificity overrides\ninput[type=\"submit\"],\ninput[type=\"reset\"],\ninput[type=\"button\"] {\n &.btn-block {\n width: 100%;\n }\n}\n","// Button variants\n//\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n\n.button-variant(@color; @background; @border) {\n color: @color;\n background-color: @background;\n border-color: @border;\n\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 25%);\n }\n &:hover {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n\n &:hover,\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 17%);\n border-color: darken(@border, 25%);\n }\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n background-image: none;\n }\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus,\n &.focus {\n background-color: @background;\n border-color: @border;\n }\n }\n\n .badge {\n color: @background;\n background-color: @color;\n }\n}\n\n// Button sizes\n.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n}\n","// Opacity\n\n.opacity(@opacity) {\n opacity: @opacity;\n // IE8 filter\n @opacity-ie: (@opacity * 100);\n filter: ~\"alpha(opacity=@{opacity-ie})\";\n}\n","//\n// Component animations\n// --------------------------------------------------\n\n// Heads up!\n//\n// We don't use the `.opacity()` mixin here since it causes a bug with text\n// fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552.\n\n.fade {\n opacity: 0;\n .transition(opacity .15s linear);\n &.in {\n opacity: 1;\n }\n}\n\n.collapse {\n display: none;\n\n &.in { display: block; }\n tr&.in { display: table-row; }\n tbody&.in { display: table-row-group; }\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n .transition-property(~\"height, visibility\");\n .transition-duration(.35s);\n .transition-timing-function(ease);\n}\n","//\n// Dropdown menus\n// --------------------------------------------------\n\n\n// Dropdown arrow/caret\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: @caret-width-base dashed;\n border-top: @caret-width-base solid ~\"\\9\"; // IE8\n border-right: @caret-width-base solid transparent;\n border-left: @caret-width-base solid transparent;\n}\n\n// The dropdown wrapper (div)\n.dropup,\n.dropdown {\n position: relative;\n}\n\n// Prevent the focus on the dropdown toggle when closing dropdowns\n.dropdown-toggle:focus {\n outline: 0;\n}\n\n// The dropdown menu (ul)\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: @zindex-dropdown;\n display: none; // none by default, but block on \"open\" of the menu\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0; // override default ul\n list-style: none;\n font-size: @font-size-base;\n text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)\n background-color: @dropdown-bg;\n border: 1px solid @dropdown-fallback-border; // IE8 fallback\n border: 1px solid @dropdown-border;\n border-radius: @border-radius-base;\n .box-shadow(0 6px 12px rgba(0,0,0,.175));\n background-clip: padding-box;\n\n // Aligns the dropdown menu to right\n //\n // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]`\n &.pull-right {\n right: 0;\n left: auto;\n }\n\n // Dividers (basically an hr) within the dropdown\n .divider {\n .nav-divider(@dropdown-divider-bg);\n }\n\n // Links within the dropdown menu\n > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: @line-height-base;\n color: @dropdown-link-color;\n white-space: nowrap; // prevent links from randomly breaking onto new lines\n }\n}\n\n// Hover/Focus state\n.dropdown-menu > li > a {\n &:hover,\n &:focus {\n text-decoration: none;\n color: @dropdown-link-hover-color;\n background-color: @dropdown-link-hover-bg;\n }\n}\n\n// Active state\n.dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-active-color;\n text-decoration: none;\n outline: 0;\n background-color: @dropdown-link-active-bg;\n }\n}\n\n// Disabled state\n//\n// Gray out text and ensure the hover/focus state remains gray\n\n.dropdown-menu > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-disabled-color;\n }\n\n // Nuke hover/focus effects\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none; // Remove CSS gradient\n .reset-filter();\n cursor: @cursor-disabled;\n }\n}\n\n// Open state for the dropdown\n.open {\n // Show the menu\n > .dropdown-menu {\n display: block;\n }\n\n // Remove the outline when :focus is triggered\n > a {\n outline: 0;\n }\n}\n\n// Menu positioning\n//\n// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown\n// menu with the parent.\n.dropdown-menu-right {\n left: auto; // Reset the default from `.dropdown-menu`\n right: 0;\n}\n// With v3, we enabled auto-flipping if you have a dropdown within a right\n// aligned nav component. To enable the undoing of that, we provide an override\n// to restore the default dropdown menu alignment.\n//\n// This is only for left-aligning a dropdown menu within a `.navbar-right` or\n// `.pull-right` nav component.\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n\n// Dropdown section headers\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: @font-size-small;\n line-height: @line-height-base;\n color: @dropdown-header-color;\n white-space: nowrap; // as with > li > a\n}\n\n// Backdrop to catch body clicks on mobile, etc.\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: (@zindex-dropdown - 10);\n}\n\n// Right aligned dropdowns\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n//\n// Just add .dropup after the standard .dropdown class and you're set, bro.\n// TODO: abstract this so that the navbar fixed styles are not placed here?\n\n.dropup,\n.navbar-fixed-bottom .dropdown {\n // Reverse the caret\n .caret {\n border-top: 0;\n border-bottom: @caret-width-base dashed;\n border-bottom: @caret-width-base solid ~\"\\9\"; // IE8\n content: \"\";\n }\n // Different positioning for bottom up menu\n .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n }\n}\n\n\n// Component alignment\n//\n// Reiterate per navbar.less and the modified component alignment there.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-right {\n .dropdown-menu {\n .dropdown-menu-right();\n }\n // Necessary for overrides of the default right aligned menu.\n // Will remove come v4 in all likelihood.\n .dropdown-menu-left {\n .dropdown-menu-left();\n }\n }\n}\n","// Horizontal dividers\n//\n// Dividers (basically an hr) within dropdowns and nav lists\n\n.nav-divider(@color: #e5e5e5) {\n height: 1px;\n margin: ((@line-height-computed / 2) - 1) 0;\n overflow: hidden;\n background-color: @color;\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n","//\n// Button groups\n// --------------------------------------------------\n\n// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle; // match .btn alignment given font-size hack above\n > .btn {\n position: relative;\n float: left;\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active,\n &.active {\n z-index: 2;\n }\n }\n}\n\n// Prevent double borders when buttons are next to each other\n.btn-group {\n .btn + .btn,\n .btn + .btn-group,\n .btn-group + .btn,\n .btn-group + .btn-group {\n margin-left: -1px;\n }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n margin-left: -5px; // Offset the first child's margin\n &:extend(.clearfix all);\n\n .btn,\n .btn-group,\n .input-group {\n float: left;\n }\n > .btn,\n > .btn-group,\n > .input-group {\n margin-left: 5px;\n }\n}\n\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n\n// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match\n.btn-group > .btn:first-child {\n margin-left: 0;\n &:not(:last-child):not(.dropdown-toggle) {\n .border-right-radius(0);\n }\n}\n// Need .dropdown-toggle since :last-child doesn't apply, given that a .dropdown-menu is used immediately after it\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n .border-left-radius(0);\n}\n\n// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group)\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-right-radius(0);\n }\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-left-radius(0);\n}\n\n// On active and open, don't show outline\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-xs > .btn { &:extend(.btn-xs); }\n.btn-group-sm > .btn { &:extend(.btn-sm); }\n.btn-group-lg > .btn { &:extend(.btn-lg); }\n\n\n// Split button dropdowns\n// ----------------------\n\n// Give the line between buttons some depth\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n\n// The clickable button for toggling the menu\n// Remove the gradient and set the same inset shadow as the :active state\n.btn-group.open .dropdown-toggle {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n\n // Show no shadow for `.btn-link` since it has no other button styles.\n &.btn-link {\n .box-shadow(none);\n }\n}\n\n\n// Reposition the caret\n.btn .caret {\n margin-left: 0;\n}\n// Carets in other button sizes\n.btn-lg .caret {\n border-width: @caret-width-large @caret-width-large 0;\n border-bottom-width: 0;\n}\n// Upside down carets for .dropup\n.dropup .btn-lg .caret {\n border-width: 0 @caret-width-large @caret-width-large;\n}\n\n\n// Vertical button groups\n// ----------------------\n\n.btn-group-vertical {\n > .btn,\n > .btn-group,\n > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n }\n\n // Clear floats so dropdown menus can be properly placed\n > .btn-group {\n &:extend(.clearfix all);\n > .btn {\n float: none;\n }\n }\n\n > .btn + .btn,\n > .btn + .btn-group,\n > .btn-group + .btn,\n > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n }\n}\n\n.btn-group-vertical > .btn {\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n &:first-child:not(:last-child) {\n .border-top-radius(@btn-border-radius-base);\n .border-bottom-radius(0);\n }\n &:last-child:not(:first-child) {\n .border-top-radius(0);\n .border-bottom-radius(@btn-border-radius-base);\n }\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-bottom-radius(0);\n }\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-top-radius(0);\n}\n\n\n// Justified button groups\n// ----------------------\n\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n > .btn,\n > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n }\n > .btn-group .btn {\n width: 100%;\n }\n\n > .btn-group .dropdown-menu {\n left: auto;\n }\n}\n\n\n// Checkbox and radio options\n//\n// In order to support the browser's form validation feedback, powered by the\n// `required` attribute, we have to \"hide\" the inputs via `clip`. We cannot use\n// `display: none;` or `visibility: hidden;` as that also hides the popover.\n// Simply visually hiding the inputs via `opacity` would leave them clickable in\n// certain cases which is prevented by using `clip` and `pointer-events`.\n// This way, we ensure a DOM element is visible to position the popover from.\n//\n// See https://github.com/twbs/bootstrap/pull/12794 and\n// https://github.com/twbs/bootstrap/pull/14559 for more information.\n\n[data-toggle=\"buttons\"] {\n > .btn,\n > .btn-group > .btn {\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0,0,0,0);\n pointer-events: none;\n }\n }\n}\n","// Single side border-radius\n\n.border-top-radius(@radius) {\n border-top-right-radius: @radius;\n border-top-left-radius: @radius;\n}\n.border-right-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-top-right-radius: @radius;\n}\n.border-bottom-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-bottom-left-radius: @radius;\n}\n.border-left-radius(@radius) {\n border-bottom-left-radius: @radius;\n border-top-left-radius: @radius;\n}\n","//\n// Input groups\n// --------------------------------------------------\n\n// Base styles\n// -------------------------\n.input-group {\n position: relative; // For dropdowns\n display: table;\n border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table\n\n // Undo padding and float of grid classes\n &[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n }\n\n .form-control {\n // Ensure that the input is always above the *appended* addon button for\n // proper border colors.\n position: relative;\n z-index: 2;\n\n // IE9 fubars the placeholder attribute in text inputs and the arrows on\n // select elements in input groups. To fix it, we float the input. Details:\n // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855\n float: left;\n\n width: 100%;\n margin-bottom: 0;\n\n &:focus {\n z-index: 3;\n }\n }\n}\n\n// Sizing options\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n .input-lg();\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n .input-sm();\n}\n\n\n// Display as table-cell\n// -------------------------\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n}\n// Addon and addon wrapper for buttons\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle; // Match the inputs\n}\n\n// Text input groups\n// -------------------------\n.input-group-addon {\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n font-weight: normal;\n line-height: 1;\n color: @input-color;\n text-align: center;\n background-color: @input-group-addon-bg;\n border: 1px solid @input-group-addon-border-color;\n border-radius: @input-border-radius;\n\n // Sizing\n &.input-sm {\n padding: @padding-small-vertical @padding-small-horizontal;\n font-size: @font-size-small;\n border-radius: @input-border-radius-small;\n }\n &.input-lg {\n padding: @padding-large-vertical @padding-large-horizontal;\n font-size: @font-size-large;\n border-radius: @input-border-radius-large;\n }\n\n // Nuke default margins from checkboxes and radios to vertically center within.\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n margin-top: 0;\n }\n}\n\n// Reset rounded corners\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n .border-right-radius(0);\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n .border-left-radius(0);\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n\n// Button input groups\n// -------------------------\n.input-group-btn {\n position: relative;\n // Jankily prevent input button groups from wrapping with `white-space` and\n // `font-size` in combination with `inline-block` on buttons.\n font-size: 0;\n white-space: nowrap;\n\n // Negative margin for spacing, position for bringing hovered/focused/actived\n // element above the siblings.\n > .btn {\n position: relative;\n + .btn {\n margin-left: -1px;\n }\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active {\n z-index: 2;\n }\n }\n\n // Negative margin to only have a 1px border between the two\n &:first-child {\n > .btn,\n > .btn-group {\n margin-right: -1px;\n }\n }\n &:last-child {\n > .btn,\n > .btn-group {\n z-index: 2;\n margin-left: -1px;\n }\n }\n}\n","//\n// Navs\n// --------------------------------------------------\n\n\n// Base class\n// --------------------------------------------------\n\n.nav {\n margin-bottom: 0;\n padding-left: 0; // Override default ul/ol\n list-style: none;\n &:extend(.clearfix all);\n\n > li {\n position: relative;\n display: block;\n\n > a {\n position: relative;\n display: block;\n padding: @nav-link-padding;\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: @nav-link-hover-bg;\n }\n }\n\n // Disabled state sets text to gray and nukes hover/tab effects\n &.disabled > a {\n color: @nav-disabled-link-color;\n\n &:hover,\n &:focus {\n color: @nav-disabled-link-hover-color;\n text-decoration: none;\n background-color: transparent;\n cursor: @cursor-disabled;\n }\n }\n }\n\n // Open dropdowns\n .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @nav-link-hover-bg;\n border-color: @link-color;\n }\n }\n\n // Nav dividers (deprecated with v3.0.1)\n //\n // This should have been removed in v3 with the dropping of `.nav-list`, but\n // we missed it. We don't currently support this anywhere, but in the interest\n // of maintaining backward compatibility in case you use it, it's deprecated.\n .nav-divider {\n .nav-divider();\n }\n\n // Prevent IE8 from misplacing imgs\n //\n // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989\n > li > a > img {\n max-width: none;\n }\n}\n\n\n// Tabs\n// -------------------------\n\n// Give the tabs something to sit on\n.nav-tabs {\n border-bottom: 1px solid @nav-tabs-border-color;\n > li {\n float: left;\n // Make the list-items overlay the bottom border\n margin-bottom: -1px;\n\n // Actual tabs (as links)\n > a {\n margin-right: 2px;\n line-height: @line-height-base;\n border: 1px solid transparent;\n border-radius: @border-radius-base @border-radius-base 0 0;\n &:hover {\n border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color;\n }\n }\n\n // Active state, and its :hover to override normal :hover\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-tabs-active-link-hover-color;\n background-color: @nav-tabs-active-link-hover-bg;\n border: 1px solid @nav-tabs-active-link-hover-border-color;\n border-bottom-color: transparent;\n cursor: default;\n }\n }\n }\n // pulling this in mainly for less shorthand\n &.nav-justified {\n .nav-justified();\n .nav-tabs-justified();\n }\n}\n\n\n// Pills\n// -------------------------\n.nav-pills {\n > li {\n float: left;\n\n // Links rendered as pills\n > a {\n border-radius: @nav-pills-border-radius;\n }\n + li {\n margin-left: 2px;\n }\n\n // Active state\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-pills-active-link-hover-color;\n background-color: @nav-pills-active-link-hover-bg;\n }\n }\n }\n}\n\n\n// Stacked pills\n.nav-stacked {\n > li {\n float: none;\n + li {\n margin-top: 2px;\n margin-left: 0; // no need for this gap between nav items\n }\n }\n}\n\n\n// Nav variations\n// --------------------------------------------------\n\n// Justified nav links\n// -------------------------\n\n.nav-justified {\n width: 100%;\n\n > li {\n float: none;\n > a {\n text-align: center;\n margin-bottom: 5px;\n }\n }\n\n > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n }\n\n @media (min-width: @screen-sm-min) {\n > li {\n display: table-cell;\n width: 1%;\n > a {\n margin-bottom: 0;\n }\n }\n }\n}\n\n// Move borders to anchors instead of bottom of list\n//\n// Mixin for adding on top the shared `.nav-justified` styles for our tabs\n.nav-tabs-justified {\n border-bottom: 0;\n\n > li > a {\n // Override margin from .nav-tabs\n margin-right: 0;\n border-radius: @border-radius-base;\n }\n\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border: 1px solid @nav-tabs-justified-link-border-color;\n }\n\n @media (min-width: @screen-sm-min) {\n > li > a {\n border-bottom: 1px solid @nav-tabs-justified-link-border-color;\n border-radius: @border-radius-base @border-radius-base 0 0;\n }\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border-bottom-color: @nav-tabs-justified-active-link-border-color;\n }\n }\n}\n\n\n// Tabbable tabs\n// -------------------------\n\n// Hide tabbable panes to start, show them when `.active`\n.tab-content {\n > .tab-pane {\n display: none;\n }\n > .active {\n display: block;\n }\n}\n\n\n// Dropdowns\n// -------------------------\n\n// Specific dropdowns\n.nav-tabs .dropdown-menu {\n // make dropdown border overlap tab border\n margin-top: -1px;\n // Remove the top rounded corners here since there is a hard edge above the menu\n .border-top-radius(0);\n}\n","//\n// Navbars\n// --------------------------------------------------\n\n\n// Wrapper and base class\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n position: relative;\n min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)\n margin-bottom: @navbar-margin-bottom;\n border: 1px solid transparent;\n\n // Prevent floats from breaking the navbar\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: @navbar-border-radius;\n }\n}\n\n\n// Navbar heading\n//\n// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy\n// styling of responsive aspects.\n\n.navbar-header {\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n }\n}\n\n\n// Navbar collapse (body)\n//\n// Group your navbar content into this for easy collapsing and expanding across\n// various device sizes. By default, this content is collapsed when <768px, but\n// will expand past that for a horizontal display.\n//\n// To start (on mobile devices) the navbar links, forms, and buttons are stacked\n// vertically and include a `max-height` to overflow in case you have too much\n// content for the user's viewport.\n\n.navbar-collapse {\n overflow-x: visible;\n padding-right: @navbar-padding-horizontal;\n padding-left: @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.1);\n &:extend(.clearfix all);\n -webkit-overflow-scrolling: touch;\n\n &.in {\n overflow-y: auto;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border-top: 0;\n box-shadow: none;\n\n &.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0; // Override default setting\n overflow: visible !important;\n }\n\n &.in {\n overflow-y: visible;\n }\n\n // Undo the collapse side padding for navbars with containers to ensure\n // alignment of right-aligned contents.\n .navbar-fixed-top &,\n .navbar-static-top &,\n .navbar-fixed-bottom & {\n padding-left: 0;\n padding-right: 0;\n }\n }\n}\n\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n .navbar-collapse {\n max-height: @navbar-collapse-max-height;\n\n @media (max-device-width: @screen-xs-min) and (orientation: landscape) {\n max-height: 200px;\n }\n }\n}\n\n\n// Both navbar header and collapse\n//\n// When a container is present, change the behavior of the header and collapse.\n\n.container,\n.container-fluid {\n > .navbar-header,\n > .navbar-collapse {\n margin-right: -@navbar-padding-horizontal;\n margin-left: -@navbar-padding-horizontal;\n\n @media (min-width: @grid-float-breakpoint) {\n margin-right: 0;\n margin-left: 0;\n }\n }\n}\n\n\n//\n// Navbar alignment options\n//\n// Display the navbar across the entirety of the page or fixed it to the top or\n// bottom of the page.\n\n// Static top (unfixed, but 100% wide) navbar\n.navbar-static-top {\n z-index: @zindex-navbar;\n border-width: 0 0 1px;\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n\n// Fix the top/bottom navbars when screen real estate supports it\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: @zindex-navbar-fixed;\n\n // Undo the rounded corners\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0; // override .navbar defaults\n border-width: 1px 0 0;\n}\n\n\n// Brand/project name\n\n.navbar-brand {\n float: left;\n padding: @navbar-padding-vertical @navbar-padding-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-computed;\n height: @navbar-height;\n\n &:hover,\n &:focus {\n text-decoration: none;\n }\n\n > img {\n display: block;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n .navbar > .container &,\n .navbar > .container-fluid & {\n margin-left: -@navbar-padding-horizontal;\n }\n }\n}\n\n\n// Navbar toggle\n//\n// Custom button for toggling the `.navbar-collapse`, powered by the collapse\n// JavaScript plugin.\n\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: @navbar-padding-horizontal;\n padding: 9px 10px;\n .navbar-vertical-align(34px);\n background-color: transparent;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n border-radius: @border-radius-base;\n\n // We remove the `outline` here, but later compensate by attaching `:hover`\n // styles to `:focus`.\n &:focus {\n outline: 0;\n }\n\n // Bars\n .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n }\n .icon-bar + .icon-bar {\n margin-top: 4px;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n display: none;\n }\n}\n\n\n// Navbar nav links\n//\n// Builds on top of the `.nav` components with its own modifier class to make\n// the nav the full height of the horizontal nav (above 768px).\n\n.navbar-nav {\n margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal;\n\n > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: @line-height-computed;\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n > li > a,\n .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n > li > a {\n line-height: @line-height-computed;\n &:hover,\n &:focus {\n background-image: none;\n }\n }\n }\n }\n\n // Uncollapse the nav\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin: 0;\n\n > li {\n float: left;\n > a {\n padding-top: @navbar-padding-vertical;\n padding-bottom: @navbar-padding-vertical;\n }\n }\n }\n}\n\n\n// Navbar form\n//\n// Extension of the `.form-inline` with some extra flavor for optimum display in\n// our navbars.\n\n.navbar-form {\n margin-left: -@navbar-padding-horizontal;\n margin-right: -@navbar-padding-horizontal;\n padding: 10px @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n\n // Mixin behavior for optimum display\n .form-inline();\n\n .form-group {\n @media (max-width: @grid-float-breakpoint-max) {\n margin-bottom: 5px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n }\n\n // Vertically center in expanded, horizontal navbar\n .navbar-vertical-align(@input-height-base);\n\n // Undo 100% width for pull classes\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n .box-shadow(none);\n }\n}\n\n\n// Dropdown menus\n\n// Menu position and menu carets\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n .border-top-radius(0);\n}\n// Menu position and menu caret support for dropups via extra dropup class\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n .border-top-radius(@navbar-border-radius);\n .border-bottom-radius(0);\n}\n\n\n// Buttons in navbars\n//\n// Vertically center a button within a navbar (when *not* in a form).\n\n.navbar-btn {\n .navbar-vertical-align(@input-height-base);\n\n &.btn-sm {\n .navbar-vertical-align(@input-height-small);\n }\n &.btn-xs {\n .navbar-vertical-align(22);\n }\n}\n\n\n// Text in navbars\n//\n// Add a class to make any element properly align itself vertically within the navbars.\n\n.navbar-text {\n .navbar-vertical-align(@line-height-computed);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin-left: @navbar-padding-horizontal;\n margin-right: @navbar-padding-horizontal;\n }\n}\n\n\n// Component alignment\n//\n// Repurpose the pull utilities as their own navbar utilities to avoid specificity\n// issues with parents and chaining. Only do this when the navbar is uncollapsed\n// though so that navbar contents properly stack and align in mobile.\n//\n// Declared after the navbar components to ensure more specificity on the margins.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-left { .pull-left(); }\n .navbar-right {\n .pull-right();\n margin-right: -@navbar-padding-horizontal;\n\n ~ .navbar-right {\n margin-right: 0;\n }\n }\n}\n\n\n// Alternate navbars\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n background-color: @navbar-default-bg;\n border-color: @navbar-default-border;\n\n .navbar-brand {\n color: @navbar-default-brand-color;\n &:hover,\n &:focus {\n color: @navbar-default-brand-hover-color;\n background-color: @navbar-default-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-default-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-default-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n\n .navbar-toggle {\n border-color: @navbar-default-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-default-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-default-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: @navbar-default-border;\n }\n\n // Dropdown menu items\n .navbar-nav {\n // Remove background color from open dropdown\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-default-link-active-bg;\n color: @navbar-default-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n > li > a {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n }\n }\n\n\n // Links in navbars\n //\n // Add a class to ensure links outside the navbar nav are colored correctly.\n\n .navbar-link {\n color: @navbar-default-link-color;\n &:hover {\n color: @navbar-default-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n }\n }\n }\n}\n\n// Inverse navbar\n\n.navbar-inverse {\n background-color: @navbar-inverse-bg;\n border-color: @navbar-inverse-border;\n\n .navbar-brand {\n color: @navbar-inverse-brand-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-brand-hover-color;\n background-color: @navbar-inverse-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-inverse-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-inverse-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n\n // Darken the responsive nav toggle\n .navbar-toggle {\n border-color: @navbar-inverse-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-inverse-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-inverse-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: darken(@navbar-inverse-bg, 7%);\n }\n\n // Dropdowns\n .navbar-nav {\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-inverse-link-active-bg;\n color: @navbar-inverse-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display\n .open .dropdown-menu {\n > .dropdown-header {\n border-color: @navbar-inverse-border;\n }\n .divider {\n background-color: @navbar-inverse-border;\n }\n > li > a {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n }\n }\n\n .navbar-link {\n color: @navbar-inverse-link-color;\n &:hover {\n color: @navbar-inverse-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n }\n }\n }\n}\n","// Navbar vertical align\n//\n// Vertically center elements in the navbar.\n// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin.\n\n.navbar-vertical-align(@element-height) {\n margin-top: ((@navbar-height - @element-height) / 2);\n margin-bottom: ((@navbar-height - @element-height) / 2);\n}\n","//\n// Utility classes\n// --------------------------------------------------\n\n\n// Floats\n// -------------------------\n\n.clearfix {\n .clearfix();\n}\n.center-block {\n .center-block();\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n\n\n// Toggling content\n// -------------------------\n\n// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n .text-hide();\n}\n\n\n// Hide from screenreaders and browsers\n//\n// Credit: HTML5 Boilerplate\n\n.hidden {\n display: none !important;\n}\n\n\n// For Affix plugin\n// -------------------------\n\n.affix {\n position: fixed;\n}\n","//\n// Breadcrumbs\n// --------------------------------------------------\n\n\n.breadcrumb {\n padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal;\n margin-bottom: @line-height-computed;\n list-style: none;\n background-color: @breadcrumb-bg;\n border-radius: @border-radius-base;\n\n > li {\n display: inline-block;\n\n + li:before {\n content: \"@{breadcrumb-separator}\\00a0\"; // Unicode space added since inline-block means non-collapsing white-space\n padding: 0 5px;\n color: @breadcrumb-color;\n }\n }\n\n > .active {\n color: @breadcrumb-active-color;\n }\n}\n","//\n// Pagination (multiple pages)\n// --------------------------------------------------\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: @line-height-computed 0;\n border-radius: @border-radius-base;\n\n > li {\n display: inline; // Remove list-style and block-level defaults\n > a,\n > span {\n position: relative;\n float: left; // Collapse white-space\n padding: @padding-base-vertical @padding-base-horizontal;\n line-height: @line-height-base;\n text-decoration: none;\n color: @pagination-color;\n background-color: @pagination-bg;\n border: 1px solid @pagination-border;\n margin-left: -1px;\n }\n &:first-child {\n > a,\n > span {\n margin-left: 0;\n .border-left-radius(@border-radius-base);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius-base);\n }\n }\n }\n\n > li > a,\n > li > span {\n &:hover,\n &:focus {\n z-index: 2;\n color: @pagination-hover-color;\n background-color: @pagination-hover-bg;\n border-color: @pagination-hover-border;\n }\n }\n\n > .active > a,\n > .active > span {\n &,\n &:hover,\n &:focus {\n z-index: 3;\n color: @pagination-active-color;\n background-color: @pagination-active-bg;\n border-color: @pagination-active-border;\n cursor: default;\n }\n }\n\n > .disabled {\n > span,\n > span:hover,\n > span:focus,\n > a,\n > a:hover,\n > a:focus {\n color: @pagination-disabled-color;\n background-color: @pagination-disabled-bg;\n border-color: @pagination-disabled-border;\n cursor: @cursor-disabled;\n }\n }\n}\n\n// Sizing\n// --------------------------------------------------\n\n// Large\n.pagination-lg {\n .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n\n// Small\n.pagination-sm {\n .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n","// Pagination\n\n.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n > li {\n > a,\n > span {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n }\n &:first-child {\n > a,\n > span {\n .border-left-radius(@border-radius);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius);\n }\n }\n }\n}\n","//\n// Pager pagination\n// --------------------------------------------------\n\n\n.pager {\n padding-left: 0;\n margin: @line-height-computed 0;\n list-style: none;\n text-align: center;\n &:extend(.clearfix all);\n li {\n display: inline;\n > a,\n > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: @pager-bg;\n border: 1px solid @pager-border;\n border-radius: @pager-border-radius;\n }\n\n > a:hover,\n > a:focus {\n text-decoration: none;\n background-color: @pager-hover-bg;\n }\n }\n\n .next {\n > a,\n > span {\n float: right;\n }\n }\n\n .previous {\n > a,\n > span {\n float: left;\n }\n }\n\n .disabled {\n > a,\n > a:hover,\n > a:focus,\n > span {\n color: @pager-disabled-color;\n background-color: @pager-bg;\n cursor: @cursor-disabled;\n }\n }\n}\n","//\n// Labels\n// --------------------------------------------------\n\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: @label-color;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n\n // Add hover effects, but only for links\n a& {\n &:hover,\n &:focus {\n color: @label-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Empty labels collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for labels in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n}\n\n// Colors\n// Contextual variations (linked labels get darker on :hover)\n\n.label-default {\n .label-variant(@label-default-bg);\n}\n\n.label-primary {\n .label-variant(@label-primary-bg);\n}\n\n.label-success {\n .label-variant(@label-success-bg);\n}\n\n.label-info {\n .label-variant(@label-info-bg);\n}\n\n.label-warning {\n .label-variant(@label-warning-bg);\n}\n\n.label-danger {\n .label-variant(@label-danger-bg);\n}\n","// Labels\n\n.label-variant(@color) {\n background-color: @color;\n\n &[href] {\n &:hover,\n &:focus {\n background-color: darken(@color, 10%);\n }\n }\n}\n","//\n// Badges\n// --------------------------------------------------\n\n\n// Base class\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: @font-size-small;\n font-weight: @badge-font-weight;\n color: @badge-color;\n line-height: @badge-line-height;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: @badge-bg;\n border-radius: @badge-border-radius;\n\n // Empty badges collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for badges in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n\n .btn-xs &,\n .btn-group-xs > .btn & {\n top: 0;\n padding: 1px 5px;\n }\n\n // Hover state, but only for links\n a& {\n &:hover,\n &:focus {\n color: @badge-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Account for badges in navs\n .list-group-item.active > &,\n .nav-pills > .active > a > & {\n color: @badge-active-color;\n background-color: @badge-active-bg;\n }\n\n .list-group-item > & {\n float: right;\n }\n\n .list-group-item > & + & {\n margin-right: 5px;\n }\n\n .nav-pills > li > a > & {\n margin-left: 3px;\n }\n}\n","//\n// Jumbotron\n// --------------------------------------------------\n\n\n.jumbotron {\n padding-top: @jumbotron-padding;\n padding-bottom: @jumbotron-padding;\n margin-bottom: @jumbotron-padding;\n color: @jumbotron-color;\n background-color: @jumbotron-bg;\n\n h1,\n .h1 {\n color: @jumbotron-heading-color;\n }\n\n p {\n margin-bottom: (@jumbotron-padding / 2);\n font-size: @jumbotron-font-size;\n font-weight: 200;\n }\n\n > hr {\n border-top-color: darken(@jumbotron-bg, 10%);\n }\n\n .container &,\n .container-fluid & {\n border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n\n .container {\n max-width: 100%;\n }\n\n @media screen and (min-width: @screen-sm-min) {\n padding-top: (@jumbotron-padding * 1.6);\n padding-bottom: (@jumbotron-padding * 1.6);\n\n .container &,\n .container-fluid & {\n padding-left: (@jumbotron-padding * 2);\n padding-right: (@jumbotron-padding * 2);\n }\n\n h1,\n .h1 {\n font-size: @jumbotron-heading-font-size;\n }\n }\n}\n","//\n// Thumbnails\n// --------------------------------------------------\n\n\n// Mixin and adjust the regular image class\n.thumbnail {\n display: block;\n padding: @thumbnail-padding;\n margin-bottom: @line-height-computed;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(border .2s ease-in-out);\n\n > img,\n a > img {\n &:extend(.img-responsive);\n margin-left: auto;\n margin-right: auto;\n }\n\n // Add a hover state for linked versions only\n a&:hover,\n a&:focus,\n a&.active {\n border-color: @link-color;\n }\n\n // Image captions\n .caption {\n padding: @thumbnail-caption-padding;\n color: @thumbnail-caption-color;\n }\n}\n","//\n// Alerts\n// --------------------------------------------------\n\n\n// Base styles\n// -------------------------\n\n.alert {\n padding: @alert-padding;\n margin-bottom: @line-height-computed;\n border: 1px solid transparent;\n border-radius: @alert-border-radius;\n\n // Headings for larger alerts\n h4 {\n margin-top: 0;\n // Specified for the h4 to prevent conflicts of changing @headings-color\n color: inherit;\n }\n\n // Provide class for links that match alerts\n .alert-link {\n font-weight: @alert-link-font-weight;\n }\n\n // Improve alignment and spacing of inner content\n > p,\n > ul {\n margin-bottom: 0;\n }\n\n > p + p {\n margin-top: 5px;\n }\n}\n\n// Dismissible alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0.\n.alert-dismissible {\n padding-right: (@alert-padding + 20);\n\n // Adjust close link position\n .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n }\n}\n\n// Alternate styles\n//\n// Generate contextual modifier classes for colorizing the alert.\n\n.alert-success {\n .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);\n}\n\n.alert-info {\n .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);\n}\n\n.alert-warning {\n .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);\n}\n\n.alert-danger {\n .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);\n}\n","// Alerts\n\n.alert-variant(@background; @border; @text-color) {\n background-color: @background;\n border-color: @border;\n color: @text-color;\n\n hr {\n border-top-color: darken(@border, 5%);\n }\n .alert-link {\n color: darken(@text-color, 10%);\n }\n}\n","//\n// Progress bars\n// --------------------------------------------------\n\n\n// Bar animations\n// -------------------------\n\n// WebKit\n@-webkit-keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n// Spec and IE10+\n@keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n\n// Bar itself\n// -------------------------\n\n// Outer container\n.progress {\n overflow: hidden;\n height: @line-height-computed;\n margin-bottom: @line-height-computed;\n background-color: @progress-bg;\n border-radius: @progress-border-radius;\n .box-shadow(inset 0 1px 2px rgba(0,0,0,.1));\n}\n\n// Bar of progress\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: @font-size-small;\n line-height: @line-height-computed;\n color: @progress-bar-color;\n text-align: center;\n background-color: @progress-bar-bg;\n .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15));\n .transition(width .6s ease);\n}\n\n// Striped bars\n//\n// `.progress-striped .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar-striped` class, which you just add to an existing\n// `.progress-bar`.\n.progress-striped .progress-bar,\n.progress-bar-striped {\n #gradient > .striped();\n background-size: 40px 40px;\n}\n\n// Call animation for the active one\n//\n// `.progress.active .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar.active` approach.\n.progress.active .progress-bar,\n.progress-bar.active {\n .animation(progress-bar-stripes 2s linear infinite);\n}\n\n\n// Variations\n// -------------------------\n\n.progress-bar-success {\n .progress-bar-variant(@progress-bar-success-bg);\n}\n\n.progress-bar-info {\n .progress-bar-variant(@progress-bar-info-bg);\n}\n\n.progress-bar-warning {\n .progress-bar-variant(@progress-bar-warning-bg);\n}\n\n.progress-bar-danger {\n .progress-bar-variant(@progress-bar-danger-bg);\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Progress bars\n\n.progress-bar-variant(@color) {\n background-color: @color;\n\n // Deprecated parent class requirement as of v3.2.0\n .progress-striped & {\n #gradient > .striped();\n }\n}\n",".media {\n // Proper spacing between instances of .media\n margin-top: 15px;\n\n &:first-child {\n margin-top: 0;\n }\n}\n\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n\n.media-body {\n width: 10000px;\n}\n\n.media-object {\n display: block;\n\n // Fix collapse in webkit from max-width: 100% and display: table-cell.\n &.img-thumbnail {\n max-width: none;\n }\n}\n\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n\n.media-middle {\n vertical-align: middle;\n}\n\n.media-bottom {\n vertical-align: bottom;\n}\n\n// Reset margins on headings for tighter default spacing\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n\n// Media list variation\n//\n// Undo default ul/ol styles\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n","//\n// List groups\n// --------------------------------------------------\n\n\n// Base class\n//\n// Easily usable on <ul>, <ol>, or <div>.\n\n.list-group {\n // No need to set list-style: none; since .list-group-item is block level\n margin-bottom: 20px;\n padding-left: 0; // reset padding because ul and ol\n}\n\n\n// Individual list items\n//\n// Use on `li`s or `div`s within the `.list-group` parent.\n\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n // Place the border on the list items and negative margin up for better styling\n margin-bottom: -1px;\n background-color: @list-group-bg;\n border: 1px solid @list-group-border;\n\n // Round the first and last items\n &:first-child {\n .border-top-radius(@list-group-border-radius);\n }\n &:last-child {\n margin-bottom: 0;\n .border-bottom-radius(@list-group-border-radius);\n }\n}\n\n\n// Interactive list items\n//\n// Use anchor or button elements instead of `li`s or `div`s to create interactive items.\n// Includes an extra `.active` modifier class for showing selected items.\n\na.list-group-item,\nbutton.list-group-item {\n color: @list-group-link-color;\n\n .list-group-item-heading {\n color: @list-group-link-heading-color;\n }\n\n // Hover state\n &:hover,\n &:focus {\n text-decoration: none;\n color: @list-group-link-hover-color;\n background-color: @list-group-hover-bg;\n }\n}\n\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n\n.list-group-item {\n // Disabled state\n &.disabled,\n &.disabled:hover,\n &.disabled:focus {\n background-color: @list-group-disabled-bg;\n color: @list-group-disabled-color;\n cursor: @cursor-disabled;\n\n // Force color to inherit for custom content\n .list-group-item-heading {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-disabled-text-color;\n }\n }\n\n // Active class on item itself, not parent\n &.active,\n &.active:hover,\n &.active:focus {\n z-index: 2; // Place active items above their siblings for proper border styling\n color: @list-group-active-color;\n background-color: @list-group-active-bg;\n border-color: @list-group-active-border;\n\n // Force color to inherit for custom content\n .list-group-item-heading,\n .list-group-item-heading > small,\n .list-group-item-heading > .small {\n color: inherit;\n }\n .list-group-item-text {\n color: @list-group-active-text-color;\n }\n }\n}\n\n\n// Contextual variants\n//\n// Add modifier classes to change text and background color on individual items.\n// Organizationally, this must come after the `:hover` states.\n\n.list-group-item-variant(success; @state-success-bg; @state-success-text);\n.list-group-item-variant(info; @state-info-bg; @state-info-text);\n.list-group-item-variant(warning; @state-warning-bg; @state-warning-text);\n.list-group-item-variant(danger; @state-danger-bg; @state-danger-text);\n\n\n// Custom content options\n//\n// Extra classes for creating well-formatted content within `.list-group-item`s.\n\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n","// List Groups\n\n.list-group-item-variant(@state; @background; @color) {\n .list-group-item-@{state} {\n color: @color;\n background-color: @background;\n\n a&,\n button& {\n color: @color;\n\n .list-group-item-heading {\n color: inherit;\n }\n\n &:hover,\n &:focus {\n color: @color;\n background-color: darken(@background, 5%);\n }\n &.active,\n &.active:hover,\n &.active:focus {\n color: #fff;\n background-color: @color;\n border-color: @color;\n }\n }\n }\n}\n","//\n// Panels\n// --------------------------------------------------\n\n\n// Base class\n.panel {\n margin-bottom: @line-height-computed;\n background-color: @panel-bg;\n border: 1px solid transparent;\n border-radius: @panel-border-radius;\n .box-shadow(0 1px 1px rgba(0,0,0,.05));\n}\n\n// Panel contents\n.panel-body {\n padding: @panel-body-padding;\n &:extend(.clearfix all);\n}\n\n// Optional heading\n.panel-heading {\n padding: @panel-heading-padding;\n border-bottom: 1px solid transparent;\n .border-top-radius((@panel-border-radius - 1));\n\n > .dropdown .dropdown-toggle {\n color: inherit;\n }\n}\n\n// Within heading, strip any `h*` tag of its default margins for spacing.\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: ceil((@font-size-base * 1.125));\n color: inherit;\n\n > a,\n > small,\n > .small,\n > small > a,\n > .small > a {\n color: inherit;\n }\n}\n\n// Optional footer (stays gray in every modifier class)\n.panel-footer {\n padding: @panel-footer-padding;\n background-color: @panel-footer-bg;\n border-top: 1px solid @panel-inner-border;\n .border-bottom-radius((@panel-border-radius - 1));\n}\n\n\n// List groups in panels\n//\n// By default, space out list group content from panel headings to account for\n// any kind of custom content between the two.\n\n.panel {\n > .list-group,\n > .panel-collapse > .list-group {\n margin-bottom: 0;\n\n .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n }\n\n // Add border top radius for first one\n &:first-child {\n .list-group-item:first-child {\n border-top: 0;\n .border-top-radius((@panel-border-radius - 1));\n }\n }\n\n // Add border bottom radius for last one\n &:last-child {\n .list-group-item:last-child {\n border-bottom: 0;\n .border-bottom-radius((@panel-border-radius - 1));\n }\n }\n }\n > .panel-heading + .panel-collapse > .list-group {\n .list-group-item:first-child {\n .border-top-radius(0);\n }\n }\n}\n// Collapse space between when there's no additional content.\n.panel-heading + .list-group {\n .list-group-item:first-child {\n border-top-width: 0;\n }\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n\n// Tables in panels\n//\n// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and\n// watch it go full width.\n\n.panel {\n > .table,\n > .table-responsive > .table,\n > .panel-collapse > .table {\n margin-bottom: 0;\n\n caption {\n padding-left: @panel-body-padding;\n padding-right: @panel-body-padding;\n }\n }\n // Add border top radius for first one\n > .table:first-child,\n > .table-responsive:first-child > .table:first-child {\n .border-top-radius((@panel-border-radius - 1));\n\n > thead:first-child,\n > tbody:first-child {\n > tr:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n border-top-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-top-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-top-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n // Add border bottom radius for last one\n > .table:last-child,\n > .table-responsive:last-child > .table:last-child {\n .border-bottom-radius((@panel-border-radius - 1));\n\n > tbody:last-child,\n > tfoot:last-child {\n > tr:last-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n border-bottom-right-radius: (@panel-border-radius - 1);\n\n td:first-child,\n th:first-child {\n border-bottom-left-radius: (@panel-border-radius - 1);\n }\n td:last-child,\n th:last-child {\n border-bottom-right-radius: (@panel-border-radius - 1);\n }\n }\n }\n }\n > .panel-body + .table,\n > .panel-body + .table-responsive,\n > .table + .panel-body,\n > .table-responsive + .panel-body {\n border-top: 1px solid @table-border-color;\n }\n > .table > tbody:first-child > tr:first-child th,\n > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n }\n > .table-bordered,\n > .table-responsive > .table-bordered {\n border: 0;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n > thead,\n > tbody {\n > tr:first-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n > tbody,\n > tfoot {\n > tr:last-child {\n > td,\n > th {\n border-bottom: 0;\n }\n }\n }\n }\n > .table-responsive {\n border: 0;\n margin-bottom: 0;\n }\n}\n\n\n// Collapsible panels (aka, accordion)\n//\n// Wrap a series of panels in `.panel-group` to turn them into an accordion with\n// the help of our collapse JavaScript plugin.\n\n.panel-group {\n margin-bottom: @line-height-computed;\n\n // Tighten up margin so it's only between panels\n .panel {\n margin-bottom: 0;\n border-radius: @panel-border-radius;\n\n + .panel {\n margin-top: 5px;\n }\n }\n\n .panel-heading {\n border-bottom: 0;\n\n + .panel-collapse > .panel-body,\n + .panel-collapse > .list-group {\n border-top: 1px solid @panel-inner-border;\n }\n }\n\n .panel-footer {\n border-top: 0;\n + .panel-collapse .panel-body {\n border-bottom: 1px solid @panel-inner-border;\n }\n }\n}\n\n\n// Contextual variations\n.panel-default {\n .panel-variant(@panel-default-border; @panel-default-text; @panel-default-heading-bg; @panel-default-border);\n}\n.panel-primary {\n .panel-variant(@panel-primary-border; @panel-primary-text; @panel-primary-heading-bg; @panel-primary-border);\n}\n.panel-success {\n .panel-variant(@panel-success-border; @panel-success-text; @panel-success-heading-bg; @panel-success-border);\n}\n.panel-info {\n .panel-variant(@panel-info-border; @panel-info-text; @panel-info-heading-bg; @panel-info-border);\n}\n.panel-warning {\n .panel-variant(@panel-warning-border; @panel-warning-text; @panel-warning-heading-bg; @panel-warning-border);\n}\n.panel-danger {\n .panel-variant(@panel-danger-border; @panel-danger-text; @panel-danger-heading-bg; @panel-danger-border);\n}\n","// Panels\n\n.panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) {\n border-color: @border;\n\n & > .panel-heading {\n color: @heading-text-color;\n background-color: @heading-bg-color;\n border-color: @heading-border;\n\n + .panel-collapse > .panel-body {\n border-top-color: @border;\n }\n .badge {\n color: @heading-bg-color;\n background-color: @heading-text-color;\n }\n }\n & > .panel-footer {\n + .panel-collapse > .panel-body {\n border-bottom-color: @border;\n }\n }\n}\n","// Embeds responsive\n//\n// Credit: Nicolas Gallagher and SUIT CSS.\n\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n\n .embed-responsive-item,\n iframe,\n embed,\n object,\n video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n }\n}\n\n// Modifier class for 16:9 aspect ratio\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n\n// Modifier class for 4:3 aspect ratio\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n","//\n// Wells\n// --------------------------------------------------\n\n\n// Base class\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: @well-bg;\n border: 1px solid @well-border;\n border-radius: @border-radius-base;\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.05));\n blockquote {\n border-color: #ddd;\n border-color: rgba(0,0,0,.15);\n }\n}\n\n// Sizes\n.well-lg {\n padding: 24px;\n border-radius: @border-radius-large;\n}\n.well-sm {\n padding: 9px;\n border-radius: @border-radius-small;\n}\n","//\n// Close icons\n// --------------------------------------------------\n\n\n.close {\n float: right;\n font-size: (@font-size-base * 1.5);\n font-weight: @close-font-weight;\n line-height: 1;\n color: @close-color;\n text-shadow: @close-text-shadow;\n .opacity(.2);\n\n &:hover,\n &:focus {\n color: @close-color;\n text-decoration: none;\n cursor: pointer;\n .opacity(.5);\n }\n\n // Additional properties for button version\n // iOS requires the button element instead of an anchor tag.\n // If you want the anchor version, it requires `href=\"#\"`.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n button& {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n }\n}\n","//\n// Modals\n// --------------------------------------------------\n\n// .modal-open - body class for killing the scroll\n// .modal - container to scroll within\n// .modal-dialog - positioning shell for the actual modal\n// .modal-content - actual modal w/ bg and corners and shit\n\n// Kill the scroll on the body\n.modal-open {\n overflow: hidden;\n}\n\n// Container that the modal scrolls within\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal;\n -webkit-overflow-scrolling: touch;\n\n // Prevent Chrome on Windows from adding a focus outline. For details, see\n // https://github.com/twbs/bootstrap/pull/10951.\n outline: 0;\n\n // When fading in the modal, animate it to slide down\n &.fade .modal-dialog {\n .translate(0, -25%);\n .transition-transform(~\"0.3s ease-out\");\n }\n &.in .modal-dialog { .translate(0, 0) }\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n\n// Shell div to position the modal with bottom padding\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n\n// Actual modal\n.modal-content {\n position: relative;\n background-color: @modal-content-bg;\n border: 1px solid @modal-content-fallback-border-color; //old browsers fallback (ie8 etc)\n border: 1px solid @modal-content-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 3px 9px rgba(0,0,0,.5));\n background-clip: padding-box;\n // Remove focus outline from opened modal\n outline: 0;\n}\n\n// Modal background\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: @zindex-modal-background;\n background-color: @modal-backdrop-bg;\n // Fade for backdrop\n &.fade { .opacity(0); }\n &.in { .opacity(@modal-backdrop-opacity); }\n}\n\n// Modal header\n// Top section of the modal w/ title and dismiss\n.modal-header {\n padding: @modal-title-padding;\n border-bottom: 1px solid @modal-header-border-color;\n &:extend(.clearfix all);\n}\n// Close icon\n.modal-header .close {\n margin-top: -2px;\n}\n\n// Title text within header\n.modal-title {\n margin: 0;\n line-height: @modal-title-line-height;\n}\n\n// Modal body\n// Where all modal content resides (sibling of .modal-header and .modal-footer)\n.modal-body {\n position: relative;\n padding: @modal-inner-padding;\n}\n\n// Footer (for actions)\n.modal-footer {\n padding: @modal-inner-padding;\n text-align: right; // right align buttons\n border-top: 1px solid @modal-footer-border-color;\n &:extend(.clearfix all); // clear it in case folks use .pull-* classes on buttons\n\n // Properly space out buttons\n .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0; // account for input[type=\"submit\"] which gets the bottom margin like all other inputs\n }\n // but override that for button groups\n .btn-group .btn + .btn {\n margin-left: -1px;\n }\n // and override it for block buttons as well\n .btn-block + .btn-block {\n margin-left: 0;\n }\n}\n\n// Measure scrollbar width for padding body during modal show/hide\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n\n// Scale up the modal\n@media (min-width: @screen-sm-min) {\n // Automatically set modal's width for larger viewports\n .modal-dialog {\n width: @modal-md;\n margin: 30px auto;\n }\n .modal-content {\n .box-shadow(0 5px 15px rgba(0,0,0,.5));\n }\n\n // Modal sizes\n .modal-sm { width: @modal-sm; }\n}\n\n@media (min-width: @screen-md-min) {\n .modal-lg { width: @modal-lg; }\n}\n","//\n// Tooltips\n// --------------------------------------------------\n\n\n// Base class\n.tooltip {\n position: absolute;\n z-index: @zindex-tooltip;\n display: block;\n // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-small;\n\n .opacity(0);\n\n &.in { .opacity(@tooltip-opacity); }\n &.top { margin-top: -3px; padding: @tooltip-arrow-width 0; }\n &.right { margin-left: 3px; padding: 0 @tooltip-arrow-width; }\n &.bottom { margin-top: 3px; padding: @tooltip-arrow-width 0; }\n &.left { margin-left: -3px; padding: 0 @tooltip-arrow-width; }\n}\n\n// Wrapper for the tooltip content\n.tooltip-inner {\n max-width: @tooltip-max-width;\n padding: 3px 8px;\n color: @tooltip-color;\n text-align: center;\n background-color: @tooltip-bg;\n border-radius: @border-radius-base;\n}\n\n// Arrows\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n// Note: Deprecated .top-left, .top-right, .bottom-left, and .bottom-right as of v3.3.1\n.tooltip {\n &.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-left .tooltip-arrow {\n bottom: 0;\n right: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.top-right .tooltip-arrow {\n bottom: 0;\n left: @tooltip-arrow-width;\n margin-bottom: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width 0;\n border-top-color: @tooltip-arrow-color;\n }\n &.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width @tooltip-arrow-width @tooltip-arrow-width 0;\n border-right-color: @tooltip-arrow-color;\n }\n &.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -@tooltip-arrow-width;\n border-width: @tooltip-arrow-width 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-left-color: @tooltip-arrow-color;\n }\n &.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-left .tooltip-arrow {\n top: 0;\n right: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n &.bottom-right .tooltip-arrow {\n top: 0;\n left: @tooltip-arrow-width;\n margin-top: -@tooltip-arrow-width;\n border-width: 0 @tooltip-arrow-width @tooltip-arrow-width;\n border-bottom-color: @tooltip-arrow-color;\n }\n}\n",".reset-text() {\n font-family: @font-family-base;\n // We deliberately do NOT reset font-size.\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: @line-height-base;\n text-align: left; // Fallback for where `start` is not supported\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n}\n","//\n// Popovers\n// --------------------------------------------------\n\n\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: @zindex-popover;\n display: none;\n max-width: @popover-max-width;\n padding: 1px;\n // Our parent element can be arbitrary since popovers are by default inserted as a sibling of their target element.\n // So reset our font and text properties to avoid inheriting weird values.\n .reset-text();\n font-size: @font-size-base;\n\n background-color: @popover-bg;\n background-clip: padding-box;\n border: 1px solid @popover-fallback-border-color;\n border: 1px solid @popover-border-color;\n border-radius: @border-radius-large;\n .box-shadow(0 5px 10px rgba(0,0,0,.2));\n\n // Offset the popover to account for the popover arrow\n &.top { margin-top: -@popover-arrow-width; }\n &.right { margin-left: @popover-arrow-width; }\n &.bottom { margin-top: @popover-arrow-width; }\n &.left { margin-left: -@popover-arrow-width; }\n}\n\n.popover-title {\n margin: 0; // reset heading margin\n padding: 8px 14px;\n font-size: @font-size-base;\n background-color: @popover-title-bg;\n border-bottom: 1px solid darken(@popover-title-bg, 5%);\n border-radius: (@border-radius-large - 1) (@border-radius-large - 1) 0 0;\n}\n\n.popover-content {\n padding: 9px 14px;\n}\n\n// Arrows\n//\n// .arrow is outer, .arrow:after is inner\n\n.popover > .arrow {\n &,\n &:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n }\n}\n.popover > .arrow {\n border-width: @popover-arrow-outer-width;\n}\n.popover > .arrow:after {\n border-width: @popover-arrow-width;\n content: \"\";\n}\n\n.popover {\n &.top > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-top-color: @popover-arrow-outer-color;\n bottom: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n bottom: 1px;\n margin-left: -@popover-arrow-width;\n border-bottom-width: 0;\n border-top-color: @popover-arrow-color;\n }\n }\n &.right > .arrow {\n top: 50%;\n left: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-right-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n left: 1px;\n bottom: -@popover-arrow-width;\n border-left-width: 0;\n border-right-color: @popover-arrow-color;\n }\n }\n &.bottom > .arrow {\n left: 50%;\n margin-left: -@popover-arrow-outer-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-bottom-color: @popover-arrow-outer-color;\n top: -@popover-arrow-outer-width;\n &:after {\n content: \" \";\n top: 1px;\n margin-left: -@popover-arrow-width;\n border-top-width: 0;\n border-bottom-color: @popover-arrow-color;\n }\n }\n\n &.left > .arrow {\n top: 50%;\n right: -@popover-arrow-outer-width;\n margin-top: -@popover-arrow-outer-width;\n border-right-width: 0;\n border-left-color: @popover-arrow-outer-fallback-color; // IE8 fallback\n border-left-color: @popover-arrow-outer-color;\n &:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: @popover-arrow-color;\n bottom: -@popover-arrow-width;\n }\n }\n}\n","//\n// Carousel\n// --------------------------------------------------\n\n\n// Wrapper for the slide container and indicators\n.carousel {\n position: relative;\n}\n\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n\n > .item {\n display: none;\n position: relative;\n .transition(.6s ease-in-out left);\n\n // Account for jankitude on images\n > img,\n > a > img {\n &:extend(.img-responsive);\n line-height: 1;\n }\n\n // WebKit CSS3 transforms for supported devices\n @media all and (transform-3d), (-webkit-transform-3d) {\n .transition-transform(~'0.6s ease-in-out');\n .backface-visibility(~'hidden');\n .perspective(1000px);\n\n &.next,\n &.active.right {\n .translate3d(100%, 0, 0);\n left: 0;\n }\n &.prev,\n &.active.left {\n .translate3d(-100%, 0, 0);\n left: 0;\n }\n &.next.left,\n &.prev.right,\n &.active {\n .translate3d(0, 0, 0);\n left: 0;\n }\n }\n }\n\n > .active,\n > .next,\n > .prev {\n display: block;\n }\n\n > .active {\n left: 0;\n }\n\n > .next,\n > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n }\n\n > .next {\n left: 100%;\n }\n > .prev {\n left: -100%;\n }\n > .next.left,\n > .prev.right {\n left: 0;\n }\n\n > .active.left {\n left: -100%;\n }\n > .active.right {\n left: 100%;\n }\n\n}\n\n// Left/right controls for nav\n// ---------------------------\n\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: @carousel-control-width;\n .opacity(@carousel-control-opacity);\n font-size: @carousel-control-font-size;\n color: @carousel-control-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n background-color: rgba(0, 0, 0, 0); // Fix IE9 click-thru bug\n // We can't have this transition here because WebKit cancels the carousel\n // animation if you trip this while in the middle of another animation.\n\n // Set gradients for backgrounds\n &.left {\n #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001));\n }\n &.right {\n left: auto;\n right: 0;\n #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5));\n }\n\n // Hover/focus state\n &:hover,\n &:focus {\n outline: 0;\n color: @carousel-control-color;\n text-decoration: none;\n .opacity(.9);\n }\n\n // Toggles\n .icon-prev,\n .icon-next,\n .glyphicon-chevron-left,\n .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n }\n .icon-prev,\n .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n }\n .icon-next,\n .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n }\n .icon-prev,\n .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n }\n\n\n .icon-prev {\n &:before {\n content: '\\2039';// SINGLE LEFT-POINTING ANGLE QUOTATION MARK (U+2039)\n }\n }\n .icon-next {\n &:before {\n content: '\\203a';// SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (U+203A)\n }\n }\n}\n\n// Optional indicator pips\n//\n// Add an unordered list with the following class and add a list item for each\n// slide your carousel holds.\n\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n\n li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid @carousel-indicator-border-color;\n border-radius: 10px;\n cursor: pointer;\n\n // IE8-9 hack for event handling\n //\n // Internet Explorer 8-9 does not support clicks on elements without a set\n // `background-color`. We cannot use `filter` since that's not viewed as a\n // background color by the browser. Thus, a hack is needed.\n // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Internet_Explorer\n //\n // For IE8, we set solid black as it doesn't support `rgba()`. For IE9, we\n // set alpha transparency for the best results possible.\n background-color: #000 \\9; // IE8\n background-color: rgba(0,0,0,0); // IE9\n }\n .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: @carousel-indicator-active-bg;\n }\n}\n\n// Optional captions\n// -----------------------------\n// Hidden by default for smaller viewports\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: @carousel-caption-color;\n text-align: center;\n text-shadow: @carousel-text-shadow;\n & .btn {\n text-shadow: none; // No shadow for button elements in carousel-caption\n }\n}\n\n\n// Scale up controls for tablets and up\n@media screen and (min-width: @screen-sm-min) {\n\n // Scale up the controls a smidge\n .carousel-control {\n .glyphicon-chevron-left,\n .glyphicon-chevron-right,\n .icon-prev,\n .icon-next {\n width: (@carousel-control-font-size * 1.5);\n height: (@carousel-control-font-size * 1.5);\n margin-top: (@carousel-control-font-size / -2);\n font-size: (@carousel-control-font-size * 1.5);\n }\n .glyphicon-chevron-left,\n .icon-prev {\n margin-left: (@carousel-control-font-size / -2);\n }\n .glyphicon-chevron-right,\n .icon-next {\n margin-right: (@carousel-control-font-size / -2);\n }\n }\n\n // Show and left align the captions\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n\n // Move up the indicators\n .carousel-indicators {\n bottom: 20px;\n }\n}\n","// Clearfix\n//\n// For modern browsers\n// 1. The space content is one way to avoid an Opera bug when the\n// contenteditable attribute is included anywhere else in the document.\n// Otherwise it causes space to appear at the top and bottom of elements\n// that are clearfixed.\n// 2. The use of `table` rather than `block` is only necessary if using\n// `:before` to contain the top-margins of child elements.\n//\n// Source: http://nicolasgallagher.com/micro-clearfix-hack/\n\n.clearfix() {\n &:before,\n &:after {\n content: \" \"; // 1\n display: table; // 2\n }\n &:after {\n clear: both;\n }\n}\n","// Center-align a block level element\n\n.center-block() {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n","// CSS image replacement\n//\n// Heads up! v3 launched with only `.hide-text()`, but per our pattern for\n// mixins being reused as classes with the same name, this doesn't hold up. As\n// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`.\n//\n// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757\n\n// Deprecated as of v3.0.1 (has been removed in v4)\n.hide-text() {\n font: ~\"0/0\" a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n\n// New mixin to use as of v3.0.1\n.text-hide() {\n .hide-text();\n}\n","//\n// Responsive: Utility classes\n// --------------------------------------------------\n\n\n// IE10 in Windows (Phone) 8\n//\n// Support for responsive views via media queries is kind of borked in IE10, for\n// Surface/desktop in split view and for Windows Phone 8. This particular fix\n// must be accompanied by a snippet of JavaScript to sniff the user agent and\n// apply some conditional CSS to *only* the Surface/desktop Windows 8. Look at\n// our Getting Started page for more information on this bug.\n//\n// For more information, see the following:\n//\n// Issue: https://github.com/twbs/bootstrap/issues/10497\n// Docs: http://getbootstrap.com/getting-started/#support-ie10-width\n// Source: http://timkadlec.com/2013/01/windows-phone-8-and-device-width/\n// Source: http://timkadlec.com/2012/10/ie10-snap-mode-and-responsive-design/\n\n@-ms-viewport {\n width: device-width;\n}\n\n\n// Visibility utilities\n// Note: Deprecated .visible-xs, .visible-sm, .visible-md, and .visible-lg as of v3.2.0\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n .responsive-invisibility();\n}\n\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n\n.visible-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-visibility();\n }\n}\n.visible-xs-block {\n @media (max-width: @screen-xs-max) {\n display: block !important;\n }\n}\n.visible-xs-inline {\n @media (max-width: @screen-xs-max) {\n display: inline !important;\n }\n}\n.visible-xs-inline-block {\n @media (max-width: @screen-xs-max) {\n display: inline-block !important;\n }\n}\n\n.visible-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-visibility();\n }\n}\n.visible-sm-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: block !important;\n }\n}\n.visible-sm-inline {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline !important;\n }\n}\n.visible-sm-inline-block {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n display: inline-block !important;\n }\n}\n\n.visible-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-visibility();\n }\n}\n.visible-md-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: block !important;\n }\n}\n.visible-md-inline {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline !important;\n }\n}\n.visible-md-inline-block {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n display: inline-block !important;\n }\n}\n\n.visible-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-visibility();\n }\n}\n.visible-lg-block {\n @media (min-width: @screen-lg-min) {\n display: block !important;\n }\n}\n.visible-lg-inline {\n @media (min-width: @screen-lg-min) {\n display: inline !important;\n }\n}\n.visible-lg-inline-block {\n @media (min-width: @screen-lg-min) {\n display: inline-block !important;\n }\n}\n\n.hidden-xs {\n @media (max-width: @screen-xs-max) {\n .responsive-invisibility();\n }\n}\n.hidden-sm {\n @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) {\n .responsive-invisibility();\n }\n}\n.hidden-md {\n @media (min-width: @screen-md-min) and (max-width: @screen-md-max) {\n .responsive-invisibility();\n }\n}\n.hidden-lg {\n @media (min-width: @screen-lg-min) {\n .responsive-invisibility();\n }\n}\n\n\n// Print utilities\n//\n// Media queries are placed on the inside to be mixin-friendly.\n\n// Note: Deprecated .visible-print as of v3.2.0\n.visible-print {\n .responsive-invisibility();\n\n @media print {\n .responsive-visibility();\n }\n}\n.visible-print-block {\n display: none !important;\n\n @media print {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n\n @media print {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n\n @media print {\n display: inline-block !important;\n }\n}\n\n.hidden-print {\n @media print {\n .responsive-invisibility();\n }\n}\n","// Responsive utilities\n\n//\n// More easily include all the states for responsive-utilities.less.\n.responsive-visibility() {\n display: block !important;\n table& { display: table !important; }\n tr& { display: table-row !important; }\n th&,\n td& { display: table-cell !important; }\n}\n\n.responsive-invisibility() {\n display: none !important;\n}\n"]} \ No newline at end of file From 0d9a472a3e5cb07b606474086884cb6d02edc498 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Thu, 31 Aug 2017 12:12:10 -0400 Subject: [PATCH 174/178] Fix requirement for additional verification logging in with WebID-TLS --- lib/models/authenticator.js | 29 ++++++---------------- test/unit/tls-authenticator-test.js | 38 +++++++++-------------------- 2 files changed, 19 insertions(+), 48 deletions(-) diff --git a/lib/models/authenticator.js b/lib/models/authenticator.js index a9e3bef7e..4e43c34d2 100644 --- a/lib/models/authenticator.js +++ b/lib/models/authenticator.js @@ -214,7 +214,7 @@ class TlsAuthenticator extends Authenticator { .then(cert => this.extractWebId(cert)) - .then(webId => this.ensureLocalUser(webId)) + .then(webId => this.loadUser(webId)) } /** @@ -304,36 +304,23 @@ class TlsAuthenticator extends Authenticator { } /** - * Ensures that the extracted WebID URI is hosted on this server. If it is, - * returns a UserAccount instance for that WebID, throws an error otherwise. + * Returns a user account instance for a given Web ID. * * @param webId {string} * - * @throws {Error} If the account is not hosted on this server - * * @return {UserAccount} */ - ensureLocalUser (webId) { + loadUser (webId) { const serverUri = this.accountManager.host.serverUri if (domainMatches(serverUri, webId)) { // This is a locally hosted Web ID - return Promise.resolve(this.accountManager.userAccountFrom({ webId })) - } - - debug(`WebID URI ${JSON.stringify(webId)} is not a local account, verifying authorized provider`) - - return this.discoverProviderFor(webId) - .then(authorizedProvider => { - debug(`Authorized provider for ${webId} is ${authorizedProvider}`) + return this.accountManager.userAccountFrom({ webId }) + } else { + debug(`WebID URI ${JSON.stringify(webId)} is not a local account, using it as an external WebID`) - if (authorizedProvider === serverUri) { // everything checks out - return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) - } - - throw new Error(`This server is not the authorized provider for Web ID ${webId}. - See https://github.com/solid/webid-oidc-spec#authorized-oidc-issuer-discovery`) - }) + return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) + } } } diff --git a/test/unit/tls-authenticator-test.js b/test/unit/tls-authenticator-test.js index 11e351c59..12596bd2b 100644 --- a/test/unit/tls-authenticator-test.js +++ b/test/unit/tls-authenticator-test.js @@ -44,14 +44,14 @@ describe('TlsAuthenticator', () => { tlsAuth.extractWebId = sinon.stub().resolves(webId) sinon.spy(tlsAuth, 'renegotiateTls') - sinon.spy(tlsAuth, 'ensureLocalUser') + sinon.spy(tlsAuth, 'loadUser') return tlsAuth.findValidUser() .then(validUser => { expect(tlsAuth.renegotiateTls).to.have.been.called() expect(connection.getPeerCertificate).to.have.been.called() expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) - expect(tlsAuth.ensureLocalUser).to.have.been.calledWith(webId) + expect(tlsAuth.loadUser).to.have.been.calledWith(webId) expect(validUser.webId).to.equal(webId) }) @@ -133,31 +133,16 @@ describe('TlsAuthenticator', () => { }) }) - describe('ensureLocalUser()', () => { - it('should throw an error if external user and this server not the authorized provider', done => { - let tlsAuth = new TlsAuthenticator({ accountManager }) - - let externalWebId = 'https://alice.someothersite.com#me' - - tlsAuth.discoverProviderFor = sinon.stub().resolves('https://another-provider.com') - - tlsAuth.ensureLocalUser(externalWebId) - .catch(err => { - expect(err.message).to.match(/This server is not the authorized provider for Web ID https:\/\/alice.someothersite.com#me/) - done() - }) - }) - + describe('loadUser()', () => { it('should return a user instance if the webid is local', () => { let tlsAuth = new TlsAuthenticator({ accountManager }) let webId = 'https://alice.example.com/#me' - return tlsAuth.ensureLocalUser(webId) - .then(user => { - expect(user.username).to.equal('alice') - expect(user.webId).to.equal(webId) - }) + let user = tlsAuth.loadUser(webId) + + expect(user.username).to.equal('alice') + expect(user.webId).to.equal(webId) }) it('should return a user instance if external user and this server is authorized provider', () => { @@ -167,11 +152,10 @@ describe('TlsAuthenticator', () => { tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') - tlsAuth.ensureLocalUser(externalWebId) - .then(user => { - expect(user.username).to.equal(externalWebId) - expect(user.webId).to.equal(externalWebId) - }) + let user = tlsAuth.loadUser(externalWebId) + + expect(user.username).to.equal(externalWebId) + expect(user.webId).to.equal(externalWebId) }) }) From e7d7ec353e2783271ddd656119ba7e69a463b27d Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Thu, 31 Aug 2017 16:23:28 -0400 Subject: [PATCH 175/178] Remove debug overhead on ACL (#566) Fixes #558. --- lib/acl-checker.js | 13 +------------ lib/handlers/allow.js | 9 +++++++-- lib/header.js | 2 ++ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 4aef8dfa8..09fd96d97 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -21,7 +21,6 @@ class ACLChecker { // Returns a fulfilled promise when the user can access the resource // in the given mode, or rejects with an HTTP error otherwise can (user, mode) { - debug(`Can ${user || 'an agent'} ${mode} ${this.resource}?`) // If this is an ACL, Control mode must be present for any operations if (this.isAcl(this.resource)) { mode = 'Control' @@ -36,13 +35,10 @@ class ACLChecker { // Check the resource's permissions return this._permissionSet .then(acls => this.checkAccess(acls, user, mode)) - .catch(err => { - debug(`Error: ${err.message}`) + .catch(() => { if (!user) { - debug('Authentication required') throw new HTTPError(401, `Access to ${this.resource} requires authorization`) } else { - debug(`${mode} access denied for ${user}`) throw new HTTPError(403, `Access to ${this.resource} denied for ${user}`) } }) @@ -94,18 +90,11 @@ class ACLChecker { return permissionSet.checkAccess(this.resource, user, mode) .then(hasAccess => { if (hasAccess) { - debug(`${mode} access permitted to ${user}`) return true } else { - debug(`${mode} access NOT permitted to ${user}`) throw new Error('ACL file found but no matching policy found') } }) - .catch(err => { - debug(`${mode} access denied to ${user}`) - debug(err) - throw err - }) } // Gets the permission set for the given ACL diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index 843cb4f63..d5b156379 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -4,6 +4,7 @@ var ACL = require('../acl-checker') var $rdf = require('rdflib') var url = require('url') var utils = require('../utils') +var debug = require('../debug.js').ACL function allow (mode) { return function allowHandler (req, res, next) { @@ -36,8 +37,12 @@ function allow (mode) { }) // Ensure the user has the required permission - req.acl.can(req.session.userId, mode) - .then(() => next(), next) + const userId = req.session.userId + req.acl.can(userId, mode) + .then(() => next(), err => { + debug(`${mode} access denied to ${userId || '(none)'}`) + next(err) + }) }) } } diff --git a/lib/header.js b/lib/header.js index d7705bfbb..0db7d3e44 100644 --- a/lib/header.js +++ b/lib/header.js @@ -112,6 +112,8 @@ function addPermissions (req, res, next) { getPermissionsFor(acl, session.userId, resource) ]) .then(([publicPerms, userPerms]) => { + debug.ACL(`Permissions for ${session.userId || '(none)'}: ${userPerms}`) + debug.ACL(`Permissions for public: ${publicPerms}`) res.set('WAC-Allow', `user="${userPerms}",public="${publicPerms}"`) }) .then(next, next) From 928ff50e0b1a84bc9b57617a60a092125967d1e8 Mon Sep 17 00:00:00 2001 From: Dmitri Zagidulin <dzagidulin@gmail.com> Date: Thu, 31 Aug 2017 16:08:17 -0400 Subject: [PATCH 176/178] Update Data Browser html file --- config/defaults.js | 3 ++- static/databrowser.html | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/config/defaults.js b/config/defaults.js index b67c66c81..ccfde26ec 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -10,5 +10,6 @@ module.exports = { 'dbPath': './.db', 'port': 8443, 'serverUri': 'https://localhost:8443', - 'webid': true + 'webid': true, + 'dataBrowserPath': 'default' } diff --git a/static/databrowser.html b/static/databrowser.html index 1dbedb912..ffdbc5474 100644 --- a/static/databrowser.html +++ b/static/databrowser.html @@ -5,16 +5,19 @@ <script type="text/javascript" src="https://linkeddata.github.io/mashlib/dist/mashlib-prealpha.js"></script> <script> document.addEventListener('DOMContentLoaded', function() { - var UI = require('mashlib') - var $rdf = UI.rdf + var UI = Mashlib - $rdf.Fetcher.crossSiteProxyTemplate = document.origin + '/xss?uri={uri}'; - var uri = window.location.href; - window.document.title = uri; - var kb = UI.store; - var subject = kb.sym(uri); - UI.outline.GotoSubject(subject, true, undefined, true, undefined); -}); + UI.rdf.Fetcher.crossSiteProxyTemplate = document.origin + '/proxy?uri={uri}' + + UI.authn.checkUser() + .then(function () { + var uri = window.location.href + window.document.title = uri + + var subject = UI.rdf.namedNode(uri) + UI.outline.GotoSubject(subject, true, undefined, true, undefined) + }) +}) </script> </head> <body> From d45d65d6d26b300c9d1a88cff78396ee89db85bc Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Fri, 1 Sep 2017 16:47:35 -0400 Subject: [PATCH 177/178] Add a /public folder in new accounts (#569) --- default-templates/new-account/public/.acl | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 default-templates/new-account/public/.acl diff --git a/default-templates/new-account/public/.acl b/default-templates/new-account/public/.acl new file mode 100644 index 000000000..c289aedfe --- /dev/null +++ b/default-templates/new-account/public/.acl @@ -0,0 +1,19 @@ +# ACL resource for the public folder +@prefix acl: <http://www.w3.org/ns/auth/acl#>. +@prefix foaf: <http://xmlns.com/foaf/0.1/>. + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read. From bae020f5193e0ff03900c8ed0f4c5e1e63af4dd1 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh <ruben@verborgh.org> Date: Sun, 3 Sep 2017 19:27:18 -0400 Subject: [PATCH 178/178] Rename the idp option into multiuser (#570) * Rename the idp option into multiuser. Closes #515. * Make renamed configuration options warnings. --- README.md | 4 +- bin/lib/options.js | 12 +++-- default-views/auth/reset-password.hbs | 2 +- lib/create-app.js | 6 +-- lib/create-server.js | 8 ++- lib/handlers/allow.js | 2 +- lib/handlers/get.js | 4 +- lib/handlers/patch.js | 2 +- lib/header.js | 2 +- lib/ldp.js | 12 ++--- lib/models/account-manager.js | 18 +++---- lib/models/solid-host.js | 2 +- lib/requests/password-reset-email-request.js | 6 +-- lib/server-config.js | 5 +- lib/utils.js | 2 +- .../integration/account-creation-oidc-test.js | 4 +- test/integration/account-creation-tls-test.js | 2 +- test/integration/account-manager-test.js | 22 ++++---- test/integration/acl-oidc-test.js | 2 +- test/integration/authentication-oidc-test.js | 2 +- test/integration/capability-discovery-test.js | 2 +- test/integration/errors-oidc-test.js | 2 +- test/integration/header-test.js | 2 +- test/integration/patch-test.js | 2 +- test/unit/account-manager-test.js | 52 +++++++++---------- test/unit/add-cert-request-test.js | 12 ++--- test/unit/email-welcome-test.js | 2 +- test/unit/password-authenticator-test.js | 8 +-- .../unit/password-reset-email-request-test.js | 18 +++---- test/unit/tls-authenticator-test.js | 2 +- test/unit/user-accounts-api-test.js | 6 +-- 31 files changed, 118 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 97b5239e7..737354f27 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ $ solid start Otherwise, if you want to use flags, this would be the equivalent ```bash -$ solid --idp --port 8443 --cert /path/to/cert --key /path/to/key --root ./accounts +$ solid --multiuser --port 8443 --cert /path/to/cert --key /path/to/key --root ./accounts ``` Your users will have a dedicated folder under `./accounts`. Also, your root domain's website will be in `./accounts/yourdomain.tld`. New users can create accounts on `/api/accounts/new` and create new certificates on `/api/accounts/cert`. An easy-to-use sign-up tool is found on `/api/accounts`. @@ -157,7 +157,7 @@ $ solid start --help --owner [value] Set the owner of the storage (overwrites the root ACL file) --ssl-key [value] Path to the SSL private key in PEM format --ssl-cert [value] Path to the SSL certificate key in PEM format - --idp Enable multi-user mode (users can sign up for accounts) + --multiuser Enable multi-user mode --corsProxy [value] Serve the CORS proxy on this path --file-browser [value] Url to file browser app (uses Warp by default) --data-browser Enable viewing RDF resources using a default data browser application (e.g. mashlib) diff --git a/bin/lib/options.js b/bin/lib/options.js index 85581a97f..f01cf9bf8 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -123,14 +123,18 @@ module.exports = [ prompt: false }, { - name: 'idp', - help: 'Enable multi-user mode (users can sign up for accounts)', - question: 'Enable multi-user mode (users can sign up for accounts)', - full: 'allow-signup', + name: 'multiuser', + help: 'Enable multi-user mode', + question: 'Enable multi-user mode', flag: true, default: false, prompt: true }, + { + name: 'idp', + help: 'Obsolete; use --multiuser', + prompt: false + }, { name: 'no-live', help: 'Disable live support through WebSockets', diff --git a/default-views/auth/reset-password.hbs b/default-views/auth/reset-password.hbs index 8821171ad..97add6cf7 100644 --- a/default-views/auth/reset-password.hbs +++ b/default-views/auth/reset-password.hbs @@ -22,7 +22,7 @@ {{/if}} <div class="row"> <div class="col-md-12"> - {{#if multiUser}} + {{#if multiuser}} <p>Please enter your account name. A password reset link will be emailed to the address you provided during account registration.</p> diff --git a/lib/create-app.js b/lib/create-app.js index f621d840f..025148713 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -56,7 +56,7 @@ function createApp (argv = {}) { // Add CORS proxy if (argv.proxy) { - console.error('The proxy configuration option has been renamed to corsProxy.') + console.warn('The proxy configuration option has been renamed to corsProxy.') argv.corsProxy = argv.proxy delete argv.proxy } @@ -187,7 +187,7 @@ function initWebId (argv, app, ldp) { host: argv.host, accountTemplatePath: argv.templates.account, store: ldp, - multiUser: argv.idp + multiuser: argv.multiuser }) app.locals.accountManager = accountManager @@ -197,7 +197,7 @@ function initWebId (argv, app, ldp) { // Set up authentication-related API endpoints and app.locals initAuthentication(app, argv) - if (argv.idp) { + if (argv.multiuser) { app.use(vhost('*', LdpMiddleware(corsSettings))) } } diff --git a/lib/create-server.js b/lib/create-server.js index 2ec1fc2d5..216a6f774 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -22,9 +22,15 @@ function createServer (argv, app) { app.use(mount, ldpApp) debug.settings('Base URL (--mount): ' + mount) + if (argv.idp) { + console.warn('The idp configuration option has been renamed to multiuser.') + argv.idp = argv.multiuser + delete argv.idp + } + var server var needsTLS = argv.sslKey || argv.sslCert || - (ldp.webid || ldp.idp) && !argv.certificateHeader + (ldp.webid || ldp.multiuser) && !argv.certificateHeader if (!needsTLS) { server = http.createServer(app) } else { diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index d5b156379..5a6abff8c 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -82,7 +82,7 @@ function readFile (uri, host, ldp, baseUri) { : uri // Determine the root file system folder to look in // TODO prettify this - var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/' // Derive the file path for the resource var documentPath = utils.uriToFilename(newPath, root) var documentUri = url.parse(documentPath) diff --git a/lib/handlers/get.js b/lib/handlers/get.js index 084bdafc1..1c43d958a 100644 --- a/lib/handlers/get.js +++ b/lib/handlers/get.js @@ -137,7 +137,7 @@ function handler (req, res, next) { function globHandler (req, res, next) { var ldp = req.app.locals.ldp - var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + req.hostname + '/' var filename = utils.uriToFilename(req.path, root) var uri = utils.getFullUri(req) const requestUri = url.resolve(uri, req.path) @@ -199,7 +199,7 @@ function aclAllow (match, req, res, callback) { return callback(true) } - var root = ldp.idp ? ldp.root + req.hostname + '/' : ldp.root + var root = ldp.multiuser ? ldp.root + req.hostname + '/' : ldp.root var relativePath = '/' + _path.relative(root, match) res.locals.path = relativePath allow('Read', req, res, function (err) { diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 9e2f67923..a5a320bcb 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -26,7 +26,7 @@ function patchHandler (req, res, next) { // Obtain details of the target resource const ldp = req.app.locals.ldp - const root = !ldp.idp ? ldp.root : `${ldp.root}${req.hostname}/` + const root = !ldp.multiuser ? ldp.root : `${ldp.root}${req.hostname}/` const target = {} target.file = utils.uriToFilename(req.path, root) target.uri = utils.getBaseUri(req) + req.originalUrl diff --git a/lib/header.js b/lib/header.js index 0db7d3e44..c46350bbf 100644 --- a/lib/header.js +++ b/lib/header.js @@ -44,7 +44,7 @@ function addLinks (res, fileMetadata) { function linksHandler (req, res, next) { var ldp = req.app.locals.ldp - var root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + req.hostname + '/' var filename = utils.uriToFilename(req.url, root) filename = path.join(filename, req.path) diff --git a/lib/ldp.js b/lib/ldp.js index 5b3522955..368b0997f 100644 --- a/lib/ldp.js +++ b/lib/ldp.js @@ -86,7 +86,7 @@ class LDP { debug.settings('Filesystem Root: ' + this.root) debug.settings('Allow WebID authentication: ' + !!this.webid) debug.settings('Live-updates: ' + !!this.live) - debug.settings('Identity Provider: ' + !!this.idp) + debug.settings('Multi-user: ' + !!this.multiuser) debug.settings('Default file browser app: ' + this.fileBrowser) debug.settings('Suppress default data browser app: ' + this.suppressDataBrowser) debug.settings('Default data browser app file path: ' + this.dataBrowserPath) @@ -142,7 +142,7 @@ class LDP { listContainer (filename, reqUri, uri, containerData, contentType, callback) { var ldp = this // var host = url.parse(uri).hostname - // var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + // var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/' // var baseUri = utils.filenameToBaseUri(filename, uri, root) var resourceGraph = $rdf.graph() @@ -259,7 +259,7 @@ class LDP { put (host, resourcePath, stream, callback) { var ldp = this - var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/' var filePath = utils.uriToFilename(resourcePath, root, host) // PUT requests not supported on containers. Use POST instead @@ -344,7 +344,7 @@ class LDP { baseUri = undefined } - var root = ldp.idp ? ldp.root + host + '/' : ldp.root + var root = ldp.multiuser ? ldp.root + host + '/' : ldp.root var filename = utils.uriToFilename(reqPath, root) ldp.readFile(filename, (err, body) => { @@ -363,7 +363,7 @@ class LDP { var range = options.range } var ldp = this - var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/' var filename = utils.uriToFilename(reqPath, root) ldp.stat(filename, function (err, stats) { @@ -430,7 +430,7 @@ class LDP { delete (host, resourcePath, callback) { var ldp = this - var root = !ldp.idp ? ldp.root : ldp.root + host + '/' + var root = !ldp.multiuser ? ldp.root : ldp.root + host + '/' var filename = utils.uriToFilename(resourcePath, root) ldp.stat(filename, function (err, stats) { if (err) { diff --git a/lib/models/account-manager.js b/lib/models/account-manager.js index d891ee074..6793bdd58 100644 --- a/lib/models/account-manager.js +++ b/lib/models/account-manager.js @@ -27,8 +27,8 @@ class AccountManager { * @param [options.emailService] {EmailService} * @param [options.tokenService] {TokenService} * @param [options.host] {SolidHost} - * @param [options.multiUser=false] {boolean} (argv.idp) Is the server running - * in multiUser mode (users can sign up for accounts) or single user + * @param [options.multiuser=false] {boolean} (argv.multiuser) Is the server running + * in multiuser mode (users can sign up for accounts) or single user * (such as a personal website). * @param [options.store] {LDP} * @param [options.pathCard] {string} @@ -45,7 +45,7 @@ class AccountManager { this.emailService = options.emailService this.tokenService = options.tokenService this.authMethod = options.authMethod || defaults.auth - this.multiUser = options.multiUser || false + this.multiuser = options.multiuser || false this.store = options.store this.pathCard = options.pathCard || 'profile/card' this.suffixURI = options.suffixURI || '#me' @@ -56,7 +56,7 @@ class AccountManager { * Factory method for new account manager creation. Usage: * * ``` - * let options = { host, multiUser, store } + * let options = { host, multiuser, store } * let accontManager = AccountManager.from(options) * ``` * @@ -137,7 +137,7 @@ class AccountManager { accountDirFor (accountName) { let accountDir - if (this.multiUser) { + if (this.multiuser) { let uri = this.accountUriFor(accountName) let hostname = url.parse(uri).hostname accountDir = path.join(this.store.root, hostname) @@ -165,11 +165,11 @@ class AccountManager { * @param [accountName] {string} * * @throws {Error} If `this.host` has not been initialized with serverUri, - * or if in multiUser mode and accountName is not provided. + * or if in multiuser mode and accountName is not provided. * @return {string} */ accountUriFor (accountName) { - let accountUri = this.multiUser + let accountUri = this.multiuser ? this.host.accountUriFor(accountName) : this.host.serverUri // single user mode @@ -335,7 +335,7 @@ class AccountManager { * @param [userData.email] {string} * @param [userData.name] {string} * - * @throws {Error} (via `accountWebIdFor()`) If in multiUser mode and no + * @throws {Error} (via `accountWebIdFor()`) If in multiuser mode and no * username passed * * @return {UserAccount} @@ -378,7 +378,7 @@ class AccountManager { } usernameFromWebId (webId) { - if (!this.multiUser) { + if (!this.multiuser) { return DEFAULT_ADMIN_USERNAME } diff --git a/lib/models/solid-host.js b/lib/models/solid-host.js index 79304fcc6..1a926e97e 100644 --- a/lib/models/solid-host.js +++ b/lib/models/solid-host.js @@ -38,7 +38,7 @@ class SolidHost { } /** - * Composes and returns an account URI for a given username, in multiUser mode. + * Composes and returns an account URI for a given username, in multi-user mode. * Usage: * * ``` diff --git a/lib/requests/password-reset-email-request.js b/lib/requests/password-reset-email-request.js index 4bff4594f..4c0f5e9ec 100644 --- a/lib/requests/password-reset-email-request.js +++ b/lib/requests/password-reset-email-request.js @@ -105,7 +105,7 @@ class PasswordResetEmailRequest extends AuthRequest { * @throws {Error} */ validate () { - if (this.accountManager.multiUser && !this.username) { + if (this.accountManager.multiuser && !this.username) { throw new Error('Username required') } } @@ -168,7 +168,7 @@ class PasswordResetEmailRequest extends AuthRequest { let params = { error: error.message, returnToUrl: this.returnToUrl, - multiUser: this.accountManager.multiUser + multiuser: this.accountManager.multiuser } res.status(error.statusCode || 400) @@ -182,7 +182,7 @@ class PasswordResetEmailRequest extends AuthRequest { renderForm () { let params = { returnToUrl: this.returnToUrl, - multiUser: this.accountManager.multiUser + multiuser: this.accountManager.multiuser } this.response.render('auth/reset-password', params) diff --git a/lib/server-config.js b/lib/server-config.js index 80e0b9387..8894a380e 100644 --- a/lib/server-config.js +++ b/lib/server-config.js @@ -37,12 +37,11 @@ function ensureDirCopyExists (fromDir, toDir) { * @param argv {Function} Express.js app object */ function ensureWelcomePage (argv) { - let multiUser = argv.idp + let { multiuser, templates } = argv let rootDir = path.resolve(argv.root) - let templates = argv.templates let serverRootDir - if (multiUser) { + if (multiuser) { serverRootDir = path.join(rootDir, argv.host.hostname) } else { serverRootDir = rootDir diff --git a/lib/utils.js b/lib/utils.js index ec4b7c19d..07851a507 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -239,6 +239,6 @@ function stripLineEndings (obj) { function reqToPath (req) { var ldp = req.app.locals.ldp - var root = ldp.idp ? ldp.root + req.hostname + '/' : ldp.root + var root = ldp.multiuser ? ldp.root + req.hostname + '/' : ldp.root return uriToFilename(req.path, root) } diff --git a/test/integration/account-creation-oidc-test.js b/test/integration/account-creation-oidc-test.js index 9bfdb7a79..0640677f2 100644 --- a/test/integration/account-creation-oidc-test.js +++ b/test/integration/account-creation-oidc-test.js @@ -20,7 +20,7 @@ describe('AccountManager (OIDC account creation tests)', function () { sslCert: path.join(__dirname, '../keys/cert.pem'), auth: 'oidc', webid: true, - idp: true, + multiuser: true, strictOrigin: true, dbPath, serverUri @@ -212,7 +212,7 @@ describe('Single User signup page', () => { sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - idp: false, + multiuser: false, strictOrigin: true }) const server = supertest(serverUri) diff --git a/test/integration/account-creation-tls-test.js b/test/integration/account-creation-tls-test.js index d345915d9..6ca99ffcc 100644 --- a/test/integration/account-creation-tls-test.js +++ b/test/integration/account-creation-tls-test.js @@ -18,7 +18,7 @@ // sslCert: path.join(__dirname, '../keys/cert.pem'), // auth: 'tls', // webid: true, -// idp: true, +// multiuser: true, // strictOrigin: true // }) // diff --git a/test/integration/account-manager-test.js b/test/integration/account-manager-test.js index 264aa8c27..215e21339 100644 --- a/test/integration/account-manager-test.js +++ b/test/integration/account-manager-test.js @@ -28,9 +28,9 @@ describe('AccountManager', () => { let host = SolidHost.from({ serverUri: 'https://localhost' }) describe('in multi user mode', () => { - let multiUser = true - let store = new LDP({ root: testAccountsDir, idp: multiUser }) - let options = { multiUser, store, host } + let multiuser = true + let store = new LDP({ root: testAccountsDir, multiuser }) + let options = { multiuser, store, host } let accountManager = AccountManager.from(options) it('resolves to true if a directory for the account exists in root', () => { @@ -51,14 +51,14 @@ describe('AccountManager', () => { }) describe('in single user mode', () => { - let multiUser = false + let multiuser = false it('resolves to true if root .acl exists in root storage', () => { let store = new LDP({ root: path.join(testAccountsDir, 'tim.localhost'), - idp: multiUser + multiuser }) - let options = { multiUser, store, host } + let options = { multiuser, store, host } let accountManager = AccountManager.from(options) return accountManager.accountExists() @@ -70,9 +70,9 @@ describe('AccountManager', () => { it('resolves to false if root .acl does not exist in root storage', () => { let store = new LDP({ root: testAccountsDir, - idp: multiUser + multiuser }) - let options = { multiUser, store, host } + let options = { multiuser, store, host } let accountManager = AccountManager.from(options) return accountManager.accountExists() @@ -85,9 +85,9 @@ describe('AccountManager', () => { describe('createAccountFor()', () => { it('should create an account directory', () => { - let multiUser = true - let store = new LDP({ root: testAccountsDir, idp: multiUser }) - let options = { host, multiUser, store, accountTemplatePath } + let multiuser = true + let store = new LDP({ root: testAccountsDir, multiuser }) + let options = { host, multiuser, store, accountTemplatePath } let accountManager = AccountManager.from(options) let userData = { diff --git a/test/integration/acl-oidc-test.js b/test/integration/acl-oidc-test.js index 20d7089ed..7b1df49cf 100644 --- a/test/integration/acl-oidc-test.js +++ b/test/integration/acl-oidc-test.js @@ -51,7 +51,7 @@ const argv = { sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - idp: true, + multiuser: true, auth: 'oidc', strictOrigin: true, host: { serverUri } diff --git a/test/integration/authentication-oidc-test.js b/test/integration/authentication-oidc-test.js index cd7a0a2ec..cd84ffea3 100644 --- a/test/integration/authentication-oidc-test.js +++ b/test/integration/authentication-oidc-test.js @@ -44,7 +44,7 @@ describe('Authentication API (OIDC)', () => { dataBrowser: false, fileBrowser: false, webid: true, - idp: false, + multiuser: false, configPath } diff --git a/test/integration/capability-discovery-test.js b/test/integration/capability-discovery-test.js index 0bbf13db9..e8d420be7 100644 --- a/test/integration/capability-discovery-test.js +++ b/test/integration/capability-discovery-test.js @@ -21,7 +21,7 @@ describe('API', () => { dataBrowser: false, fileBrowser: false, webid: true, - idp: false, + multiuser: false, configPath } diff --git a/test/integration/errors-oidc-test.js b/test/integration/errors-oidc-test.js index bd6445b89..d085920a5 100644 --- a/test/integration/errors-oidc-test.js +++ b/test/integration/errors-oidc-test.js @@ -16,7 +16,7 @@ describe('OIDC error handling', function () { sslCert: path.join(__dirname, '../keys/cert.pem'), auth: 'oidc', webid: true, - idp: false, + multiuser: false, strictOrigin: true, dbPath, serverUri diff --git a/test/integration/header-test.js b/test/integration/header-test.js index fc4cb6c75..5e76bf53a 100644 --- a/test/integration/header-test.js +++ b/test/integration/header-test.js @@ -5,7 +5,7 @@ const supertest = require('supertest') const serverOptions = { root: path.join(__dirname, '../resources/headers'), - idp: false, + multiuser: false, webid: true, sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), diff --git a/test/integration/patch-test.js b/test/integration/patch-test.js index cd4aa5b9f..e517d8dfe 100644 --- a/test/integration/patch-test.js +++ b/test/integration/patch-test.js @@ -13,7 +13,7 @@ const root = path.join(__dirname, '../resources/patch') const serverOptions = { root, serverUri, - idp: false, + multiuser: false, webid: true, sslKey: path.join(__dirname, '../keys/key.pem'), sslCert: path.join(__dirname, '../keys/cert.pem'), diff --git a/test/unit/account-manager-test.js b/test/unit/account-manager-test.js index 9080cb9b7..01872c2e4 100644 --- a/test/unit/account-manager-test.js +++ b/test/unit/account-manager-test.js @@ -32,7 +32,7 @@ describe('AccountManager', () => { let config = { host, authMethod: 'oidc', - multiUser: true, + multiuser: true, store: {}, emailService: {}, tokenService: {} @@ -41,7 +41,7 @@ describe('AccountManager', () => { let mgr = AccountManager.from(config) expect(mgr.host).to.equal(config.host) expect(mgr.authMethod).to.equal(config.authMethod) - expect(mgr.multiUser).to.equal(config.multiUser) + expect(mgr.multiuser).to.equal(config.multiuser) expect(mgr.store).to.equal(config.store) expect(mgr.emailService).to.equal(config.emailService) expect(mgr.tokenService).to.equal(config.tokenService) @@ -56,7 +56,7 @@ describe('AccountManager', () => { describe('accountUriFor', () => { it('should compose account uri for an account in multi user mode', () => { let options = { - multiUser: true, + multiuser: true, host: SolidHost.from({ serverUri: 'https://localhost' }) } let mgr = AccountManager.from(options) @@ -67,7 +67,7 @@ describe('AccountManager', () => { it('should compose account uri for an account in single user mode', () => { let options = { - multiUser: false, + multiuser: false, host: SolidHost.from({ serverUri: 'https://localhost' }) } let mgr = AccountManager.from(options) @@ -80,7 +80,7 @@ describe('AccountManager', () => { describe('accountWebIdFor()', () => { it('should compose a web id uri for an account in multi user mode', () => { let options = { - multiUser: true, + multiuser: true, host: SolidHost.from({ serverUri: 'https://localhost' }) } let mgr = AccountManager.from(options) @@ -90,7 +90,7 @@ describe('AccountManager', () => { it('should compose a web id uri for an account in single user mode', () => { let options = { - multiUser: false, + multiuser: false, host: SolidHost.from({ serverUri: 'https://localhost' }) } let mgr = AccountManager.from(options) @@ -101,9 +101,9 @@ describe('AccountManager', () => { describe('accountDirFor()', () => { it('should match the solid root dir config, in single user mode', () => { - let multiUser = false - let store = new LDP({ root: testAccountsDir, idp: multiUser }) - let options = { multiUser, store, host } + let multiuser = false + let store = new LDP({ root: testAccountsDir, multiuser }) + let options = { multiuser, store, host } let accountManager = AccountManager.from(options) let accountDir = accountManager.accountDirFor('alice') @@ -111,10 +111,10 @@ describe('AccountManager', () => { }) it('should compose the account dir in multi user mode', () => { - let multiUser = true - let store = new LDP({ root: testAccountsDir, idp: multiUser }) + let multiuser = true + let store = new LDP({ root: testAccountsDir, multiuser }) let host = SolidHost.from({ serverUri: 'https://localhost' }) - let options = { multiUser, store, host } + let options = { multiuser, store, host } let accountManager = AccountManager.from(options) let accountDir = accountManager.accountDirFor('alice') @@ -124,11 +124,11 @@ describe('AccountManager', () => { describe('userAccountFrom()', () => { describe('in multi user mode', () => { - let multiUser = true + let multiuser = true let options, accountManager beforeEach(() => { - options = { host, multiUser } + options = { host, multiuser } accountManager = AccountManager.from(options) }) @@ -172,11 +172,11 @@ describe('AccountManager', () => { }) describe('in single user mode', () => { - let multiUser = false + let multiuser = false let options, accountManager beforeEach(() => { - options = { host, multiUser } + options = { host, multiuser } accountManager = AccountManager.from(options) }) @@ -247,7 +247,7 @@ describe('AccountManager', () => { it('should throw an error if webId is missing', (done) => { let emptyUserData = {} let userAccount = UserAccount.from(emptyUserData) - let options = { host, multiUser: true } + let options = { host, multiuser: true } let accountManager = AccountManager.from(options) accountManager.getProfileGraphFor(userAccount) @@ -267,7 +267,7 @@ describe('AccountManager', () => { let userData = { webId } let userAccount = UserAccount.from(userData) - let options = { host, multiUser: true, store } + let options = { host, multiuser: true, store } let accountManager = AccountManager.from(options) expect(userAccount.webId).to.equal(webId) @@ -289,7 +289,7 @@ describe('AccountManager', () => { let userData = { webId } let userAccount = UserAccount.from(userData) - let options = { host, multiUser: true, store } + let options = { host, multiuser: true, store } let accountManager = AccountManager.from(options) let profileGraph = rdf.graph() @@ -302,8 +302,8 @@ describe('AccountManager', () => { describe('rootAclFor()', () => { it('should return the server root .acl in single user mode', () => { - let store = new LDP({ suffixAcl: '.acl', idp: false }) - let options = { host, multiUser: false, store } + let store = new LDP({ suffixAcl: '.acl', multiuser: false }) + let options = { host, multiuser: false, store } let accountManager = AccountManager.from(options) let userAccount = UserAccount.from({ username: 'alice' }) @@ -314,8 +314,8 @@ describe('AccountManager', () => { }) it('should return the profile root .acl in multi user mode', () => { - let store = new LDP({ suffixAcl: '.acl', idp: true }) - let options = { host, multiUser: true, store } + let store = new LDP({ suffixAcl: '.acl', multiuser: true }) + let options = { host, multiuser: true, store } let accountManager = AccountManager.from(options) let userAccount = UserAccount.from({ username: 'alice' }) @@ -342,7 +342,7 @@ describe('AccountManager', () => { getGraph: sinon.stub().resolves(rootAclGraph) } - let options = { host, multiUser: true, store } + let options = { host, multiuser: true, store } let accountManager = AccountManager.from(options) return accountManager.loadAccountRecoveryEmail(userAccount) @@ -361,7 +361,7 @@ describe('AccountManager', () => { getGraph: sinon.stub().resolves(emptyGraph) } - let options = { host, multiUser: true, store } + let options = { host, multiuser: true, store } let accountManager = AccountManager.from(options) return accountManager.loadAccountRecoveryEmail(userAccount) @@ -374,7 +374,7 @@ describe('AccountManager', () => { describe('passwordResetUrl()', () => { it('should return a token reset validation url', () => { let tokenService = new TokenService() - let options = { host, multiUser: true, tokenService } + let options = { host, multiuser: true, tokenService } let accountManager = AccountManager.from(options) diff --git a/test/unit/add-cert-request-test.js b/test/unit/add-cert-request-test.js index 1d1544c12..f6e7f2ce4 100644 --- a/test/unit/add-cert-request-test.js +++ b/test/unit/add-cert-request-test.js @@ -30,8 +30,8 @@ beforeEach(() => { describe('AddCertificateRequest', () => { describe('fromParams()', () => { it('should throw a 401 error if session.userId is missing', () => { - let multiUser = true - let options = { host, multiUser, authMethod: 'oidc' } + let multiuser = true + let options = { host, multiuser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = { @@ -49,10 +49,10 @@ describe('AddCertificateRequest', () => { }) describe('createRequest()', () => { - let multiUser = true + let multiuser = true it('should call certificate.generateCertificate()', () => { - let options = { host, multiUser, authMethod: 'oidc' } + let options = { host, multiuser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = { @@ -78,10 +78,10 @@ describe('AddCertificateRequest', () => { }) describe('accountManager.addCertKeyToGraph()', () => { - let multiUser = true + let multiuser = true it('should add certificate data to a graph', () => { - let options = { host, multiUser, authMethod: 'oidc' } + let options = { host, multiuser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let userData = { username: 'alice' } diff --git a/test/unit/email-welcome-test.js b/test/unit/email-welcome-test.js index e81769b70..86e3935b2 100644 --- a/test/unit/email-welcome-test.js +++ b/test/unit/email-welcome-test.js @@ -26,7 +26,7 @@ beforeEach(() => { host, emailService, authMethod: 'oidc', - multiUser: true + multiuser: true } accountManager = AccountManager.from(mgrConfig) }) diff --git a/test/unit/password-authenticator-test.js b/test/unit/password-authenticator-test.js index c93c094f0..e57b63f1e 100644 --- a/test/unit/password-authenticator-test.js +++ b/test/unit/password-authenticator-test.js @@ -138,11 +138,11 @@ describe('PasswordAuthenticator', () => { }) describe('in Multi User mode', () => { - let multiUser = true + let multiuser = true let serverUri = 'https://example.com' let host = SolidHost.from({ serverUri }) - let accountManager = AccountManager.from({ multiUser, host }) + let accountManager = AccountManager.from({ multiuser, host }) let aliceRecord = { webId: 'https://alice.example.com/profile/card#me' } let mockUserStore = { @@ -187,11 +187,11 @@ describe('PasswordAuthenticator', () => { }) describe('in Single User mode', () => { - let multiUser = false + let multiuser = false let serverUri = 'https://localhost:8443' let host = SolidHost.from({ serverUri }) - let accountManager = AccountManager.from({ multiUser, host }) + let accountManager = AccountManager.from({ multiuser, host }) let aliceRecord = { webId: 'https://localhost:8443/profile/card#me' } let mockUserStore = { diff --git a/test/unit/password-reset-email-request-test.js b/test/unit/password-reset-email-request-test.js index 74fed9035..9bda0e210 100644 --- a/test/unit/password-reset-email-request-test.js +++ b/test/unit/password-reset-email-request-test.js @@ -60,7 +60,7 @@ describe('PasswordResetEmailRequest', () => { it('should create an instance and render a reset password form', () => { let returnToUrl = 'https://example.com/resource' let username = 'alice' - let accountManager = { multiUser: true } + let accountManager = { multiuser: true } let req = { app: { locals: { accountManager } }, @@ -73,7 +73,7 @@ describe('PasswordResetEmailRequest', () => { PasswordResetEmailRequest.get(req, res) expect(res.render).to.have.been.calledWith('auth/reset-password', - { returnToUrl, multiUser: true }) + { returnToUrl, multiuser: true }) }) }) @@ -87,7 +87,7 @@ describe('PasswordResetEmailRequest', () => { let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiUser: true, store }) + let accountManager = AccountManager.from({ host, multiuser: true, store }) accountManager.accountExists = sinon.stub().resolves(true) accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') accountManager.sendPasswordResetEmail = sinon.stub().resolves() @@ -107,9 +107,9 @@ describe('PasswordResetEmailRequest', () => { }) describe('validate()', () => { - it('should throw an error if username is missing in multiUser mode', () => { + it('should throw an error if username is missing in multi-user mode', () => { let host = SolidHost.from({ serverUri: 'https://example.com' }) - let accountManager = AccountManager.from({ host, multiUser: true }) + let accountManager = AccountManager.from({ host, multiuser: true }) let request = new PasswordResetEmailRequest({ accountManager }) @@ -118,7 +118,7 @@ describe('PasswordResetEmailRequest', () => { it('should not throw an error if username is missing in single user mode', () => { let host = SolidHost.from({ serverUri: 'https://example.com' }) - let accountManager = AccountManager.from({ host, multiUser: false }) + let accountManager = AccountManager.from({ host, multiuser: false }) let request = new PasswordResetEmailRequest({ accountManager }) @@ -130,7 +130,7 @@ describe('PasswordResetEmailRequest', () => { it('should handle the post request', () => { let host = SolidHost.from({ serverUri: 'https://example.com' }) let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiUser: true, store }) + let accountManager = AccountManager.from({ host, multiuser: true, store }) accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') accountManager.sendPasswordResetEmail = sinon.stub().resolves() accountManager.accountExists = sinon.stub().resolves(true) @@ -159,7 +159,7 @@ describe('PasswordResetEmailRequest', () => { it('should return a UserAccount instance based on username', () => { let host = SolidHost.from({ serverUri: 'https://example.com' }) let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiUser: true, store }) + let accountManager = AccountManager.from({ host, multiuser: true, store }) accountManager.accountExists = sinon.stub().resolves(true) let username = 'alice' @@ -175,7 +175,7 @@ describe('PasswordResetEmailRequest', () => { it('should throw an error if the user does not exist', done => { let host = SolidHost.from({ serverUri: 'https://example.com' }) let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiUser: true, store }) + let accountManager = AccountManager.from({ host, multiuser: true, store }) accountManager.accountExists = sinon.stub().resolves(false) let username = 'alice' diff --git a/test/unit/tls-authenticator-test.js b/test/unit/tls-authenticator-test.js index 12596bd2b..27c2be391 100644 --- a/test/unit/tls-authenticator-test.js +++ b/test/unit/tls-authenticator-test.js @@ -14,7 +14,7 @@ const SolidHost = require('../../lib/models/solid-host') const AccountManager = require('../../lib/models/account-manager') const host = SolidHost.from({ serverUri: 'https://example.com' }) -const accountManager = AccountManager.from({ host, multiUser: true }) +const accountManager = AccountManager.from({ host, multiuser: true }) describe('TlsAuthenticator', () => { describe('fromParams()', () => { diff --git a/test/unit/user-accounts-api-test.js b/test/unit/user-accounts-api-test.js index 4436064fd..f420dd002 100644 --- a/test/unit/user-accounts-api-test.js +++ b/test/unit/user-accounts-api-test.js @@ -25,11 +25,11 @@ beforeEach(() => { describe('api/accounts/user-accounts', () => { describe('newCertificate()', () => { describe('in multi user mode', () => { - let multiUser = true - let store = new LDP({ root: testAccountsDir, idp: multiUser }) + let multiuser = true + let store = new LDP({ root: testAccountsDir, multiuser }) it('should throw a 400 error if spkac param is missing', done => { - let options = { host, store, multiUser, authMethod: 'oidc' } + let options = { host, store, multiuser, authMethod: 'oidc' } let accountManager = AccountManager.from(options) let req = {

${data.resetUrl}