diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ad6496 --- /dev/null +++ b/.gitignore @@ -0,0 +1,333 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,node,python +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,node,python + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +#!.vscode/settings.json +#!.vscode/tasks.json +#!.vscode/launch.json +#!.vscode/extensions.json +#!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,node,python + diff --git a/connections/firefoxaccounts.js b/connections/firefoxaccounts.js index 19d936f..d993b90 100644 --- a/connections/firefoxaccounts.js +++ b/connections/firefoxaccounts.js @@ -60,6 +60,12 @@ function firefoxAccountsConnection(accessToken, ctx, cb) { user_id: id_token.sub, picture: p.avatar, preferredLanguage: p.locale, + // Mozilla accounts (formerly Firefox Accounts), allows mixed case characters in their email property + // I'm adding this comment here in case we want to actually enforce email case conformity in the future + // Although, it will need extensive testing to ensure we aren't breaking anything. In the meantime, we will + // need to account for the fact that this and other IdP connectors return email with mixed case. + // So until otherwise decided, we allow mixed case email. + // email: p.email.toLowerCase(), email: p.email, email_verified: true, fxa_sub: id_token.sub, diff --git a/rules/Global-Function-Declarations.js b/rules/Global-Function-Declarations.js index 866fe06..1f247b2 100644 --- a/rules/Global-Function-Declarations.js +++ b/rules/Global-Function-Declarations.js @@ -3,7 +3,7 @@ function globalFunctionDeclaration(user, context, callback) { // This rule MUST be at the top of the rule list (FIRST) or other rules WILL FAIL // with a NON RECOVERABLE error, and thus LOGIN WILL FAIL FOR USERS - // Since we do not use the /continue endpoint let's make sure we explictly fail with an ErrorUnauthorized + // Since we do not use the /continue endpoint let's make sure we explictly fail with an UnauthorizedError // otherwise it is possible to continue the session even after a postError redirect is set. if (context.protocol === "redirect-callback") { return callback(new UnauthorizedError('The /continue endpoint is not allowed'), user, context); @@ -35,8 +35,10 @@ function globalFunctionDeclaration(user, context, callback) { ); skey = undefined; // auth0 compiler does not allow 'delete' so we undefine instead + + var domain = context.tenant === "dev" ? "sso.allizom.org" : "sso.mozilla.com"; rcontext.redirect = { - url: `https://sso.mozilla.com/forbidden?error=${token}` + url: `https://${domain}/forbidden?error=${token}` }; return rcontext; diff --git a/rules/force-ldap-logins-over-ldap.js b/rules/force-ldap-logins-over-ldap.js index 758cb0b..4365ad3 100644 --- a/rules/force-ldap-logins-over-ldap.js +++ b/rules/force-ldap-logins-over-ldap.js @@ -5,11 +5,16 @@ function forceLDAPLoginsOverLDAP(user, context, callback) { 'jijaIzcZmFCDRtV74scMb9lI87MtYNTA', // mozillians.org Verification Client ]; + // The domain strings in this array should always be declared here in lowercase const MOZILLA_STAFF_DOMAINS = [ - 'mozilla.com', // Main corp domain - 'mozillafoundation.org', // Main org domain - 'getpocket.com', // Pocket domain - 'thunderbird.net', // MZLA domain + 'mozilla.com', // Main corp domain + 'mozillafoundation.org', // Main org domain + 'getpocket.com', // Pocket domain + 'thunderbird.net', // MZLA domain + 'readitlater.com', + 'mozilla-japan.org', + 'mozilla.ai', + 'mozilla.vc' ]; // Sanity checks @@ -31,7 +36,8 @@ function forceLDAPLoginsOverLDAP(user, context, callback) { // 'ad' is LDAP - Force LDAP users to log with LDAP here if (context.connectionStrategy !== 'ad') { for (let domain of MOZILLA_STAFF_DOMAINS) { - if (user.email.endsWith(domain)) { + // we need to sanitize the email address to lowercase before matching so we can catch users with upper/mixed case email addresses + if (user.email.toLowerCase().endsWith(domain)) { console.log(`Staff or LDAP user attempted to login with the wrong login method. We only allow ad (LDAP) for staff: ${user.email}`); return callback(null, user, global.postError('staffmustuseldap', context)); } diff --git a/rules/link-users-by-email-with-metadata.js b/rules/link-users-by-email-with-metadata.js index 50f9ac2..8d150ea 100644 --- a/rules/link-users-by-email-with-metadata.js +++ b/rules/link-users-by-email-with-metadata.js @@ -22,58 +22,49 @@ function linkUsersByEmailWithMetadata(user, context, callback) { } const userApiUrl = auth0.baseUrl + '/users'; - const userSearchApiUrl = auth0.baseUrl + '/users-by-email?'; - const params = new URLSearchParams({ - email: user.email, - }); - - fetch(userSearchApiUrl + params.toString(), { + const opts = { headers: { Authorization: 'Bearer ' + auth0.accessToken, }, - }) - .then((response) => { - if (response.status !== 200) - return callback(new Error('API Call failed: ' + response.body)); - return response.json(); - }) - .then((data) => { - // Ignore non-verified users - data = data.filter((u) => u.email_verified); + }; - if (data.length === 1) { - // The user logged in with an identity which is the only one Auth0 knows about - // Do not perform any account linking - return callback(null, user, context); - } + // Since email addresses within auth0 are allowed to be mixed case and the /user-by-email search endpoint + // is case sensitive, we need to search for both situations. In the first search we search by "this" users email + // which might be mixed case (or not). Our second search is for the lowercase equivalent but only if two searches + // would be different. + const searchMultipleEmailCases = async () => { + const emailUrl = new URL('/api/v2/users-by-email', auth0.baseUrl); + emailUrl.searchParams.append('email', user.email); - if (data.length === 2) { - // Auth0 is aware of 2 identities with the same email address which means - // that the user just logged in with a new identity that hasn't been linked - // into the other existing identity. Here we pass the other account to the - // linking function - return linkAccount(data.filter((u) => u.user_id !== user.user_id)[0]); - } else { - // data.length is > 2 which, post November 2020 when all identities were - // force linked manually, shouldn't be possible - var error_message = - `Error linking account ${user.user_id} as there are ` + - `over 2 identities with the email address ${user.email} ` + - data.map((x) => x.user_id).join(); - console.log(error_message); - publishSNSMessage( - `${error_message}\n\ndata : ${JSON.stringify( - data - )}\nuser : ${JSON.stringify(user)}` - ); - return callback(new Error(error_message)); + const emailUrlToLower = new URL('/api/v2/users-by-email', auth0.baseUrl); + emailUrlToLower.searchParams.append('email', user.email.toLowerCase()); + + let fetchPromiseArray = [fetch(emailUrl.toString(), opts)]; + // if this user is mixed case, we need to also search for the lower case equivalent + if (user.email !== user.email.toLowerCase()) { + fetchPromiseArray.push(...fetch(emailUrlToLower.toString(), opts)); + } + // Call one (or two) api calls to the /user-by-email api endpoint + const responsePromises = await Promise.all(fetchPromiseArray); + + // Map each response to its JSON conversion promise + const jsonPromises = responsePromises.map(response => { + if (!response.ok) { + return callback(new Error('API Call failed: ' + response.body)); } - }) - .catch((err) => { - return callback(err); + return response.json(); }); + // await all json responses promises to resolve + const allResponses = await Promise.all(jsonPromises); + + // flatten the array of arrays to get one array of profiles + const mergedProfiles = allResponses.flat(); + + return mergedProfiles; + }; + const linkAccount = (otherProfile) => { // sanity check if both accounts have LDAP as primary // we should NOT link these accounts and simply allow the user to continue logging in. @@ -105,6 +96,7 @@ function linkUsersByEmailWithMetadata(user, context, callback) { // non-ldap, account priority does not matter and neither does the metadata of // the secondary account. + // Link the accounts try { fetch(userApiUrl + '/' + primaryUser.user_id + '/identities', { method: 'post', @@ -131,38 +123,41 @@ function linkUsersByEmailWithMetadata(user, context, callback) { } catch(err) { console.log('An unknown error occurred while linking accounts: ' + err); return callback(err); - }; + } }; - const publishSNSMessage = (message) => { - if ( - !('aws_logging_sns_topic_arn' in configuration) || - !('aws_logging_access_key_id' in configuration) || - !('aws_logging_secret_key' in configuration) - ) { - console.log('Missing Auth0 AWS SNS logging configuration values'); - return false; - } + // Search for multiple accounts of the same user to link + searchMultipleEmailCases() + .then((data) => { + // Ignore non-verified users + data = data.filter((u) => u.email_verified); - const SNS_TOPIC_ARN = configuration.aws_logging_sns_topic_arn; - const ACCESS_KEY_ID = configuration.aws_logging_access_key_id; - const SECRET_KEY = configuration.aws_logging_secret_key; - - let AWS = require('aws-sdk@2.1416.0'); - let sns = new AWS.SNS({ - apiVersion: '2010-03-31', - accessKeyId: ACCESS_KEY_ID, - secretAccessKey: SECRET_KEY, - region: 'us-west-2', - logger: console, - }); - const params = { - Message: message, - TopicArn: SNS_TOPIC_ARN, - }; - sns.publish(params, function (err, data) { - if (err) console.log(err, err.stack); // an error occurred - else console.log(data); // successful response + if (data.length <= 1) { + // The user logged in with an identity which is the only one Auth0 knows about + // or no data returned + // Do not perform any account linking + return callback(null, user, context); + } + + if (data.length === 2) { + // Auth0 is aware of 2 identities with the same email address which means + // that the user just logged in with a new identity that hasn't been linked + // into the other existing identity. Here we pass the other account to the + // linking function + linkAccount(data.filter((u) => u.user_id !== user.user_id)[0]); + } else { + // data.length is > 2 which, post November 2020 when all identities were + // force linked manually, shouldn't be possible + var error_message = + `Error linking account ${user.user_id} as there are ` + + `over 2 identities with the email address ${user.email} ` + + data.map((x) => x.user_id).join(); + console.log(error_message); + return callback(new Error(error_message)); + } + }) + .catch((err) => { + console.log('An unknown error occurred while linking accounts: ' + err); + return callback(err); }); - }; } diff --git a/tests/modules/global/auth0.js b/tests/modules/global/auth0.js index df64b8c..7e635d6 100644 --- a/tests/modules/global/auth0.js +++ b/tests/modules/global/auth0.js @@ -5,7 +5,7 @@ // functions of the Auth0 object. module.exports = { - baseUrl: "testing", + baseUrl: "https://example.com", accessToken: "testing", users: { updateAppMetadata: (user_id, app_metadata) => {