├── commitlint.config.js ├── readme-images ├── search.png ├── logal-login.png ├── plugin-settings.png └── local-registration.png ├── tsconfig.json ├── .env ├── .npmignore ├── .gitattributes ├── LICENSE ├── plugin.json ├── package.json ├── static └── lib │ └── admin.js ├── templates └── admin │ └── plugins │ └── fusionauth-oidc.tpl ├── .gitignore ├── .eslintrc ├── README.md └── library.js /commitlint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { extends: ['@commitlint/config-angular'] }; 4 | -------------------------------------------------------------------------------- /readme-images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphere/nodebb-plugin-sunbird-oidc/master/readme-images/search.png -------------------------------------------------------------------------------- /readme-images/logal-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphere/nodebb-plugin-sunbird-oidc/master/readme-images/logal-login.png -------------------------------------------------------------------------------- /readme-images/plugin-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphere/nodebb-plugin-sunbird-oidc/master/readme-images/plugin-settings.png -------------------------------------------------------------------------------- /readme-images/local-registration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sphere/nodebb-plugin-sunbird-oidc/master/readme-images/local-registration.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es5" 7 | ] 8 | } 9 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | DATABASE_USER=fusionauth 4 | DATABASE_PASSWORD=hkaLBM3RVnyYeYeqE3WI1w2e4Avpy0Wd5O3s3 5 | 6 | ES_JAVA_OPTS=-Xms256m -Xmx256m 7 | 8 | FUSIONAUTH_MEMORY=256M -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sftp-config.json 2 | node_modules/ 3 | nodebb/ 4 | build/ 5 | .idea/ 6 | docker-compose.yml 7 | .env 8 | tsconfig.json 9 | commitlint.config.json 10 | .gitattributes 11 | .eslintrc 12 | *.ts 13 | readme-images 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2014, psychobunny 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nodebb-plugin-sunbird-oidc", 3 | "name": "Sunbird SSO Plugin for NodeBB", 4 | "description": "Authenticate to Sunbird identity provider.", 5 | "url": "https://github.com/vrayulu/nodebb-plugin-sunbird-oidc", 6 | "library": "./library.js", 7 | "templates": "templates", 8 | "hooks": [ 9 | { 10 | "hook": "static:app.load", 11 | "method": "init" 12 | }, 13 | { 14 | "hook": "static:user.delete", 15 | "method": "deleteUserData" 16 | }, 17 | { 18 | "hook": "filter:user.whitelistFields", 19 | "method": "whitelistFields" 20 | }, 21 | { 22 | "hook": "filter:auth.init", 23 | "method": "bindStrategy" 24 | }, 25 | { 26 | "hook": "filter:admin.header.build", 27 | "method": "bindMenuOption" 28 | }, 29 | { 30 | "hook": "action:settings.set", 31 | "method": "bindStrategy" 32 | }, 33 | { 34 | "hook": "filter:user.logout", 35 | "method": "redirectLogout" 36 | }, 37 | { 38 | "hook": "filter:category.getFields", 39 | "method": "categoryRead" 40 | }, 41 | { 42 | "hook": "filter:topic.getFields", 43 | "method": "topicRead" 44 | }, 45 | {"hook": "filter:topic.create", "method": "topicCreate" } 46 | ], 47 | "acpScripts": [ 48 | "static/lib/admin.js" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodebb-plugin-sunbird-oidc", 3 | "version": "1.0.17", 4 | "description": "Authenticate to Sunbird identity provider.", 5 | "main": "library.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/vrayulu/nodebb-plugin-sunbird-oidc" 9 | }, 10 | "keywords": [ 11 | "nodebb", 12 | "plugin", 13 | "sunbird", 14 | "oauth", 15 | "oauth2", 16 | "sso", 17 | "single sign on", 18 | "login", 19 | "registration" 20 | ], 21 | "license": "BSD-2-Clause", 22 | "readmeFilename": "README.md", 23 | "dependencies": { 24 | "async": "^2", 25 | "fs": "0.0.1-security", 26 | "lodash": "^4.17.20", 27 | "mongoose": "^5.11.9", 28 | "passport-custom": "^1.1.1", 29 | "request": "2.81.0", 30 | "request-promise": "^4.2.6", 31 | "uid2": "0.0.x" 32 | }, 33 | "nbbpm": { 34 | "compatibility": "^1.0.1" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "^8.3.5", 38 | "@commitlint/config-angular": "^8.3.4", 39 | "@types/async": "^3.0.8", 40 | "@types/express": "^4.17.3", 41 | "@types/passport-oauth2": "^1.4.8", 42 | "eslint": "^6.8.0", 43 | "eslint-config-airbnb-base": "^14.1.0", 44 | "eslint-plugin-import": "^2.20.1", 45 | "husky": "^4.2.3", 46 | "lint-staged": "^10.0.8", 47 | "typescript": "^3.8.3" 48 | }, 49 | "husky": { 50 | "hooks": {} 51 | }, 52 | "lint-staged": { 53 | "*.js": [ 54 | "eslint --fix", 55 | "git add" 56 | ] 57 | }, 58 | "scripts": {} 59 | } 60 | -------------------------------------------------------------------------------- /static/lib/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals $, app, socket, define, fetch */ 4 | define('admin/plugins/fusionauth-oidc', ['settings'], function (settings) { 5 | const updateField = function (selector, value) { 6 | if (!value) { 7 | return; 8 | } 9 | 10 | const element = $(selector); 11 | if (!element.val()) { 12 | element.val(value); 13 | } 14 | }; 15 | 16 | return { 17 | init: function () { 18 | settings.load('fusionauth-oidc', $('#fusionauth-oidc-settings')); 19 | 20 | const save = function (form) { 21 | settings.save('fusionauth-oidc', form, function () { 22 | app.alert({ 23 | type: 'success', 24 | alert_id: 'sso-oidc-saved', 25 | title: 'Settings Saved', 26 | message: 'If you changed the email claim, you will need to restart before it will be applied.', 27 | clickfn: function () { 28 | socket.emit('admin.reload'); 29 | }, 30 | }); 31 | }); 32 | }; 33 | 34 | $('#save').on('click', function () { 35 | const form = $('#fusionauth-oidc-settings'); 36 | 37 | // Trim the fields 38 | form.find('input[data-trim="true"]').each(function () { 39 | $(this).val($.trim($(this).val())); 40 | }); 41 | 42 | const baseURL = $('input[name="discoveryBaseURL"]').val(); 43 | if (baseURL) { 44 | const errorFunc = () => { 45 | app.alert({ 46 | type: 'danger', 47 | alert_id: 'sso-oidc-error', 48 | title: 'An error occurred ', 49 | message: 'An error has occurred while trying to discover the OIDC configuration. Make sure that this platform supports the well known configuration url and that you have the right url.', 50 | }); 51 | }; 52 | 53 | const timeout = setTimeout(errorFunc, 5000); 54 | 55 | fetch(baseURL + '/.well-known/openid-configuration') 56 | .then((res) => res.json()) 57 | .then((json) => { 58 | clearTimeout(timeout); 59 | updateField('input[name="authorizationEndpoint"]', json.authorization_endpoint); 60 | updateField('input[name="tokenEndpoint"]', json.token_endpoint); 61 | updateField('input[name="userInfoEndpoint"]', json.userinfo_endpoint); 62 | updateField('input[name="logoutEndpoint"]', json.end_session_endpoint); 63 | save(form); 64 | }) 65 | .catch((e) => { 66 | clearTimeout(timeout); 67 | console.error(e); 68 | errorFunc(); 69 | }); 70 | } else { 71 | save(form); 72 | } 73 | }); 74 | }, 75 | }; 76 | }); 77 | -------------------------------------------------------------------------------- /templates/admin/plugins/fusionauth-oidc.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Sunbird SSO Configuration

6 |
7 |
8 |
Configure NodeBB to authenticate usign an Sunbird 9 | identity provider. 10 |
11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | 217 | sftp-config.json 218 | node_modules/ 219 | 220 | #################### 221 | # JetBrains 222 | #################### 223 | 224 | .idea 225 | nodebb 226 | src/passport-fusionauth-oidc.js 227 | /src/passport-fusionauth-oidc.js.map 228 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parserOptions": { 4 | "sourceType": "script" 5 | }, 6 | 7 | "rules": { 8 | // Customized 9 | "handle-callback-err": [ "error","^(e$|(e|(.*(_e|E)))rr)" ], 10 | "comma-dangle": ["error", { 11 | "arrays": "always-multiline", 12 | "objects": "always-multiline", 13 | "imports": "always-multiline", 14 | "exports": "always-multiline", 15 | "functions": "never" 16 | }], 17 | "no-empty": ["error", { "allowEmptyCatch": true }], 18 | "no-underscore-dangle": "off", 19 | "no-console": "off", 20 | "no-mixed-operators": ["error", { "allowSamePrecedence": true }], 21 | "strict": ["error", "global"], 22 | "consistent-return": "off", 23 | "func-names": "off", 24 | "no-tabs": "off", 25 | "indent": ["error", "tab"], 26 | "no-eq-null": "off", 27 | "camelcase": "off", 28 | "no-new": "off", 29 | "no-shadow": "off", 30 | "no-use-before-define": ["error", "nofunc"], 31 | "no-prototype-builtins": "off", 32 | "new-cap": "off", 33 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 34 | "object-curly-newline": "off", 35 | "no-restricted-globals": "off", 36 | "function-paren-newline": "off", 37 | "import/no-unresolved": "error", 38 | "quotes": ["error", "single", { 39 | "avoidEscape": true, 40 | "allowTemplateLiterals": true 41 | }], 42 | "no-else-return": [ "error", { "allowElseIf": true } ], 43 | "operator-linebreak": [ "error", "after" ], 44 | 45 | // ES6 46 | "prefer-rest-params": "off", 47 | "prefer-spread": "off", 48 | "prefer-arrow-callback": "off", 49 | "prefer-template": "off", 50 | "no-var": "off", 51 | "object-shorthand": "off", 52 | "vars-on-top": "off", 53 | "prefer-destructuring": "off", 54 | 55 | // TODO 56 | "import/no-extraneous-dependencies": "off", 57 | "import/no-dynamic-require": "off", 58 | "import/newline-after-import": "off", 59 | "no-bitwise": "off", 60 | "global-require": "off", 61 | "max-len": "off", 62 | "no-param-reassign": "off", 63 | "no-restricted-syntax": "off", 64 | "no-script-url": "off", 65 | "default-case": "off", 66 | "linebreak-style": "off", 67 | 68 | // "no-multi-assign": "off", 69 | // "one-var": "off", 70 | // "no-undef": "off", 71 | // "max-nested-callbacks": "off", 72 | // "no-mixed-requires": "off", 73 | // "brace-style": "off", 74 | // "max-statements-per-line": "off", 75 | // "no-unused-vars": "off", 76 | // "no-mixed-spaces-and-tabs": "off", 77 | // "no-useless-concat": "off", 78 | // "require-jsdoc": "off", 79 | // "eqeqeq": "off", 80 | // "no-negated-condition": "off", 81 | // "one-var-declaration-per-line": "off", 82 | // "no-lonely-if": "off", 83 | // "radix": "off", 84 | // "no-else-return": "off", 85 | // "no-useless-escape": "off", 86 | // "block-scoped-var": "off", 87 | // "operator-assignment": "off", 88 | // "yoda": "off", 89 | // "no-loop-func": "off", 90 | // "no-void": "off", 91 | // "valid-jsdoc": "off", 92 | // "no-cond-assign": "off", 93 | // "no-redeclare": "off", 94 | // "no-unreachable": "off", 95 | // "no-nested-ternary": "off", 96 | // "operator-linebreak": "off", 97 | // "guard-for-in": "off", 98 | // "no-unneeded-ternary": "off", 99 | // "no-sequences": "off", 100 | // "no-extend-native": "off", 101 | // "no-shadow-restricted-names": "off", 102 | // "no-extra-boolean-cast": "off", 103 | // "no-path-concat": "off", 104 | // "no-unused-expressions": "off", 105 | // "no-return-assign": "off", 106 | // "no-restricted-modules": "off", 107 | // "object-curly-spacing": "off", 108 | // "indent": "off", 109 | // "padded-blocks": "off", 110 | // "eol-last": "off", 111 | // "lines-around-directive": "off", 112 | // "strict": "off", 113 | // "comma-dangle": "off", 114 | // "no-multi-spaces": "off", 115 | // "quotes": "off", 116 | // "keyword-spacing": "off", 117 | // "no-mixed-operators": "off", 118 | // "comma-spacing": "off", 119 | // "no-trailing-spaces": "off", 120 | // "key-spacing": "off", 121 | // "no-multiple-empty-lines": "off", 122 | // "spaced-comment": "off", 123 | // "space-in-parens": "off", 124 | // "block-spacing": "off", 125 | // "quote-props": "off", 126 | // "space-unary-ops": "off", 127 | // "no-empty": "off", 128 | // "dot-notation": "off", 129 | // "func-call-spacing": "off", 130 | // "array-bracket-spacing": "off", 131 | // "object-property-newline": "off", 132 | // "no-continue": "off", 133 | // "no-extra-semi": "off", 134 | // "no-spaced-func": "off", 135 | // "no-useless-return": "off" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NodeBB FusionAuth OpenID Connect 2 | 3 | This plugin is developed and supported by FusionAuth but it is intended to work with any OpenID Connect identity provider. 4 | 5 | If you find any issue or a missing feature, please open an issue. 6 | 7 | ## Features 8 | 9 | * OpenID Connect Authentication 10 | * Authorization Code Grant 11 | * Hot reloading of the authentication strategy 12 | * The configuration with the exception of the `Email claim` can be loaded without restarting NodeBB. 13 | 14 | 15 | ## Developing 16 | 17 | 1. Run `npm link` in this directory 18 | 1. Go to your NodeBB directory and run `npm link nodebb-plugin-fusionauth-oidc` 19 | 1. Run `./nodebb build` 20 | 1. Run `./nodebb dev` 21 | 22 | 23 | ## Installation 24 | 25 | 1. Launch NodeBB 26 | 1. Go to the admin portal 27 | 1. Select `Extend > Plugins` from the menu 28 | 1. Click `Find Plugins` from the sub-menu 29 | 1. Search for `fusionauth-oidc` 30 | ![Search](./readme-images/search.png) 31 | 1. Click the `Install` button on the `nodebb-plugin-fusionauth-oidc` result 32 | 1. Click `Installed` from the sub-menu 33 | 1. Find the `nodebb-plugin-fusionauth-oidc` plugin and click `Activate` 34 | 1. Rebuild and restart NodeBB 35 | 1. Refresh the page to regenerate the menu 36 | 37 | 38 | ## Configuring 39 | 40 | To configure the OpenID Connect plugin, perform the following steps to navigate to the configuration panel. 41 | 42 | 1. Select `Plugins > OpenID Connect` from the menu 43 | 1. You are now ready to configure the OpenID Connect plugin 44 | 45 | You will need your Client Id, Client Secret and the endpoint URLs provided to you by your OpenID Connect identity provider. 46 | 47 | If your OpenID Connect identity provider supports discovery, then you only need to enter the Client Id, Client Secret and Discovery base URL. If this is not available to you or discovery is not successful, you may also enter each endpoint manually . 48 | 49 | Once you complete this configuration and save the form, you will need to restart NodeBB for the configuration to take effect. 50 | 51 | ![Plugin Settings](./readme-images/plugin-settings.png) 52 | 53 | | Field | Description | 54 | | ----- | ----------- | 55 | | Client ID | The unique Client Id provided to you by your IdP. | 56 | | Client secret | The client secret provided to you by your IdP. In some cases this may not be provided to you if the IdP allows for non-confidential clients. | 57 | |Discovery URL | When provided, this URL will be used to find the OpenID Connect discovery document. This URL will be appended with `.well-known/openid-configuration`. If this URL returns a JSON discovery document the remaining endpoints will be automatically resolved when you press the Save button. | 58 | |Authorization endpoint | The fully qualified URL to the Authorization endpoint. | 59 | |Token endpoint | The fully qualified URL to the Token endpoint. | 60 | |Userinfo endpoint | The fully qualified URL to the Userinfo endpoint. | 61 | |Logout endpoint | The fully qualified URL of the OpenID Connect logout endpoint. If configured, the browser will be redirected to this URL when you click logout in NodeBB. | 62 | |Email claim | The name of the claim found in the response from the Userinfo endpoint that identifies the user's email address. This is generally `email` and is the default value. | 63 | |Roles claim | If present, on login we will check this claim for the value `"admin"` and if present, give the user access to the admin panel. The claim value can either be a string or array of strings. Anything else will be treated as the user having no roles. | 64 | 65 | This plugin will work with any OpenID Connect identity provider. If you are using FusionAuth, the values needed for this configuration will be found in your Application OAuth configuration. For more information, review the [OAuth configuration](https://fusionauth.io/docs/v1/tech/core-concepts/applications#oauth) tab of the Application configuration in the FusionAuth documentation. 66 | 67 | If your provider requires a callback url then use `https:///auth/fusionauth-oidc/callback` 68 | 69 | ### Additional Configuration 70 | 71 | If you want to skip the login page and always use the configured OpenID Connect identity provider for authentication you will need to disable Login Login and Local Registration. 72 | 73 | #### To disable Local Login: 74 | 1. Select `Manage > Privileges` from the menu 75 | 1. Uncheck the appropriate boxes under the `Local Login` column in the `Group Privileges` table 76 | ![Local Login](./readme-images/logal-login.png) 77 | 78 | #### To disable Local Registration: 79 | 1. Select `Settings > User` from the menu 80 | 1. Scroll down to the `User Registration` section and set `Registration Type` to `No Registration` 81 | 1. Click the Save icon 82 | ![Local Registration](./readme-images/local-registration.png) 83 | 84 | Once both Local Login and Local Registration have been disabled, the default login page will be skipped and the user will be automatically redirected to the OpenID Connect login page. 85 | 86 | #### Recovery 87 | If you need to login locally you can manually add the following parameter `/login?local=1` to your URL and you will be taken to the default login page. 88 | 89 | #### Developer Notes 90 | 91 | If you make changes to the plugin you will need to rebuild and reload. You can do this manually or via the UI. 92 | -------------------------------------------------------------------------------- /library.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | ((module) => { 4 | const User = require.main.require('./src/user'); 5 | const Groups = require.main.require('./src/groups'); 6 | const db = require.main.require('./src/database'); 7 | const authenticationController = require.main.require('./src/controllers/authentication'); 8 | const Settings = require.main.require('./src/settings'); 9 | const writeApi = require.main.require('./node_modules/nodebb-plugin-write-api/lib/auth.js'); 10 | 11 | const url = require('url'); 12 | const uid = require('uid2'); 13 | const async = require('async'); 14 | const request = require('request-promise'); 15 | const CustomStrategy = require('passport-custom'); 16 | 17 | const passport = module.parent.require('passport'); 18 | const nconf = module.parent.require('nconf'); 19 | const winston = module.parent.require('winston'); 20 | console.log('nodebb plugin sunbird oidc working'); 21 | let lodash = require('lodash'); 22 | const fs = require('fs'); 23 | const constants = { 24 | name: 'sunbird-oidc', 25 | callbackURL: '/auth/sunbird-oidc/callback', 26 | createUserURL: '/api/user/v1/create', 27 | createUserToken: '/api/v2/users/:uid/tokens', 28 | pluginSettingsURL: '/admin/plugins/fusionauth-oidc', 29 | pluginSettings: new Settings('fusionauth-oidc', '1.0.0', { 30 | // Default settings 31 | clientId: "", 32 | clientSecret: "", 33 | emailClaim: 'email', 34 | discoveryBaseURL: "", 35 | authorizationEndpoint: "", 36 | tokenEndpoint: "", 37 | ssoTokenEndpoint: "", 38 | userInfoEndpoint: "", 39 | emailDomain: "" 40 | }, false, false), 41 | }; 42 | 43 | const Oidc = {}; 44 | 45 | Oidc.checkUserTokens = function(uid) { 46 | return new Promise((resolve, reject) => { 47 | writeApi.getTokens(uid, (err, tokens) => { 48 | if(err){ 49 | console.log('SB OIDC Token: Error while reading user token', err.message) 50 | reject(err); 51 | } 52 | if(lodash.isEmpty(tokens)) { 53 | console.log('SB OIDC Token: No tokens yet, creating new user token') 54 | writeApi.generateToken(uid, (error, newToken) => { 55 | if(error){ 56 | console.log('SB OIDC Token: Error while creating new user token', error.message) 57 | reject(error); 58 | } 59 | console.log('SB OIDC Token:New token created successfully') 60 | resolve(newToken); 61 | }) 62 | } else { 63 | console.log('SB OIDC Token: user token already created') 64 | resolve(tokens[0]); 65 | } 66 | }); 67 | }) 68 | } 69 | 70 | 71 | Oidc.createUser = async function (req, res, next) { 72 | console.log("SB OIDC: Entry Log for ", req.originalUrl) 73 | var msgid = (req.body.params && req.body.params.msgid)?req.body.params.msgid:""; 74 | var response = { 75 | "id": "api.discussions.user.create", 76 | "ver": "1.0", 77 | "params": { 78 | "resmsgid": msgid, 79 | "msgid": msgid 80 | }, 81 | "responseCode": "" 82 | } 83 | if(req.body && req.body.request && req.body.request.username && req.body.request.identifier){ 84 | const settings = constants.pluginSettings.getWrapper(); 85 | var email = req.body.request.username; 86 | var mailformat = /\S+@\S/; 87 | var userName = req.body.request.username; 88 | if((req.body.request.username).match(mailformat)) { 89 | userName = (req.body.request.username).split('@')[0]; 90 | } else { 91 | email = req.body.request.username + '@' + settings.emailDomain; 92 | } 93 | Oidc.login({ 94 | oAuthid: req.body.request.identifier, 95 | username: userName, 96 | fullname: req.body.request.fullname ? req.body.request.fullname : null, 97 | email: email, 98 | rolesEnabled: settings.rolesClaim && settings.rolesClaim.length !== 0, 99 | isAdmin: false, 100 | }, async (err, user) => { 101 | const userSlug = await User.getUserField(user.uid, 'userslug'); 102 | console.log("'SB OIDC Token: userSlug-", userSlug); 103 | console.log('SB OIDC Token: request original url:', req.originalUrl); 104 | console.log('SB OIDC Token: request path url:', req.path); 105 | console.log('SB OIDC Token: request url:', req.url); 106 | console.log('SB OIDC Token: request protocol url:', req.protocol); 107 | if(err && err === 'UserExists'){ 108 | response.responseCode = "CLIENT_ERROR"; 109 | response.responseCode = "400"; 110 | response.params.status = "unsuccessful"; 111 | response.params.msg = "User already Exists"; 112 | response.result = { "userId" : user, "userSlug": userSlug, "userName": req.body.request.username }; 113 | console.log('SB OIDC Token: getting checkUserTokens for already register user'); 114 | try { 115 | const userToken = await Oidc.checkUserTokens(user.uid); 116 | console.log("SB OIDC Token: user tokens here", userToken); 117 | res.setHeader("nodebb_auth_token", userToken); 118 | res.json(response); 119 | } catch(error) { 120 | console.log("SB OIDC Token: Error While checkig the tokens", error) 121 | } 122 | }else if(user){ 123 | response.responseCode = "OK" 124 | response.params.status = "successful"; 125 | response.params.msg = "User created successful"; 126 | response.result = { "userId" : user, "userSlug": userSlug, "userName": req.body.request.username }; 127 | console.log('SB OIDC Token: getting checkUserTokens for newly register user'); 128 | try { 129 | const userToken = await Oidc.checkUserTokens(user.uid); 130 | res.setHeader("nodebb_auth_token", userToken) 131 | res.json(response); 132 | }catch (error) { 133 | console.log("SB OIDC Token: Error While checkig the tokens", error) 134 | } 135 | 136 | }else{ 137 | console.log("SB OIDC: Error Log for ", req.originalUrl) 138 | response.responseCode = "SERVER_ERROR" 139 | response.responseCode = "400" 140 | console.log(err); 141 | res.json(response); 142 | } 143 | 144 | }); 145 | }else{ 146 | response.responseCode = "CLIENT_ERROR" 147 | response.responseCode = "400" 148 | res.json(response); 149 | } 150 | } 151 | 152 | /** 153 | * Sets up the router bindings for the settings page 154 | * @param params 155 | * @param callback 156 | */ 157 | Oidc.init = function (params, callback) { 158 | winston.verbose('Setting up Sunbird OIDC bindings/routes'); 159 | 160 | function render(req, res) { 161 | res.render('admin/plugins/fusionauth-oidc', { 162 | baseUrl: nconf.get('url'), 163 | }); 164 | } 165 | 166 | params.router.get(constants.pluginSettingsURL, params.middleware.admin.buildHeader, render); 167 | params.router.get('/api/admin/plugins/fusionauth-oidc', render); 168 | params.router.post(constants.createUserURL, Oidc.createUser); 169 | 170 | 171 | callback(); 172 | }; 173 | 174 | 175 | function writeFile(message) { 176 | const ts = new Date().toLocaleString(); 177 | const data = `${ts}: ${message}`; 178 | fs.appendFile('./logs/redis.log',`${data}\n`, function(err,res){ 179 | if(err) { 180 | console.log('SB Error at file write:', err) 181 | } 182 | }) 183 | } 184 | 185 | Oidc.topicRead = function(paramas, callback) { 186 | if (paramas) { 187 | writeFile('SB:Topic read'); 188 | console.log('SB:Topic read '); 189 | } 190 | callback(null, paramas); 191 | } 192 | 193 | Oidc.categoryRead = function(paramas, callback) { 194 | if (paramas) { 195 | writeFile('SB:Category read '); 196 | console.log('SB:Category read'); 197 | } 198 | callback(null, paramas); 199 | } 200 | 201 | Oidc.topicCreate = function(paramas, callback) { 202 | if (paramas) { 203 | writeFile('SB:Topic create api'); 204 | console.log('SB:Topic create api '); 205 | } 206 | callback(null, paramas); 207 | } 208 | 209 | 210 | Oidc.getAccessTokenFromCode = async function (settings, code) { 211 | const options = { 212 | method: 'POST', 213 | url: settings.tokenEndpoint, 214 | form: { 215 | grant_type: 'authorization_code', 216 | client_id: settings.clientId, 217 | client_secret: settings.clientSecret, 218 | code: code, 219 | redirect_uri: settings.callbackURL 220 | } 221 | } 222 | return request(options); 223 | }; 224 | 225 | Oidc.getAccessTokenFromId = async function (settings, id) { 226 | const tokenUrl = new URL(settings.ssoTokenEndpoint); 227 | tokenUrl.searchParams.append("id", id); 228 | const options = { 229 | method: 'GET', 230 | url: tokenUrl.href 231 | } 232 | return request(options); 233 | }; 234 | 235 | Oidc.getUserInfo = async function (settings, accessToken) { 236 | const options = { 237 | method: 'GET', 238 | url: settings.userInfoEndpoint, 239 | headers: { 240 | 'Authorization': 'Bearer ' + accessToken 241 | } 242 | } 243 | return request(options); 244 | }; 245 | 246 | /** 247 | * Binds the passport strategy to the global passport object 248 | * @param strategies The global list of strategies 249 | * @param callback 250 | */ 251 | Oidc.bindStrategy = function (strategies, callback) { 252 | winston.verbose('Setting up openid connect'); 253 | 254 | callback = callback || function () { 255 | }; 256 | 257 | constants.pluginSettings.sync(function (err) { 258 | if (err) { 259 | return callback(err); 260 | } 261 | 262 | const settings = constants.pluginSettings.getWrapper(); 263 | //added as removed from UI 264 | settings.emailClaim = 'email'; 265 | // If we are missing any settings 266 | if (!settings.clientId || 267 | !settings.clientSecret || 268 | !settings.emailClaim || 269 | !settings.authorizationEndpoint || 270 | !settings.tokenEndpoint || 271 | !settings.ssoTokenEndpoint || 272 | !settings.userInfoEndpoint || 273 | !settings.emailDomain) { 274 | winston.info('Sunbird SSO will not be available until it is configured!'); 275 | return callback(); 276 | } 277 | 278 | settings.callbackURL = nconf.get('url') + constants.callbackURL; 279 | 280 | // // If you call this twice it will overwrite the first. 281 | passport.use(constants.name, new CustomStrategy( 282 | async function(req, callback) { 283 | var profile = {}; 284 | // if request is not yet authenticated, redirect to login page 285 | if (!req.query || (!req.query.code && !req.query.access_token)) { 286 | var state = uid(24); 287 | const authUrl = new URL(settings.authorizationEndpoint); 288 | authUrl.searchParams.append("client_id", settings.clientId); 289 | authUrl.searchParams.append("state", state); 290 | authUrl.searchParams.append("response_type", "code"); 291 | authUrl.searchParams.append("scope", ['openid', settings.emailClaim]); 292 | authUrl.searchParams.append("redirect_uri", settings.callbackURL); 293 | req.session.ssoState = state; 294 | this.redirect(authUrl.href); 295 | } else { 296 | var accessToken = ""; 297 | if (req.query.access_token) { 298 | // if request has access token, use it to fetch user info 299 | accessToken = req.query.access_token; 300 | } else if (req.query.id) { 301 | // if request has id, invoke sunbird session create API to get token for this id 302 | try { 303 | var response = await Oidc.getAccessTokenFromId(settings, req.query.id); 304 | var json = JSON.parse(response); 305 | accessToken = json.access_token; 306 | } catch (err) { 307 | return callback(err); 308 | } 309 | } else if (req.query.code) { 310 | // if request has code, get access token from keycloak 311 | try { 312 | var response = await Oidc.getAccessTokenFromCode(settings, req.query.code); 313 | var json = JSON.parse(response); 314 | accessToken = json.access_token; 315 | } catch (err) { 316 | return callback(err); 317 | } 318 | } 319 | if (req.query.returnTo && req.query.returnTo != '') { 320 | // if returnTo path is provided, set it to session for nodebb to redirect to the specified URL after login 321 | req.session.returnTo = req.query.returnTo; 322 | } 323 | try { 324 | // fetch user info 325 | var userInfo = await Oidc.getUserInfo(settings, accessToken); 326 | profile = JSON.parse(userInfo); 327 | } catch (err) { 328 | return callback(err); 329 | } 330 | 331 | // username must be present 332 | if (!profile.preferred_username || profile.preferred_username == '') { 333 | return callback(new Error('Username was missing from the user.')); 334 | } 335 | 336 | // construct the email using the username and domain name 337 | var email = profile.preferred_username + '@' + settings.emailDomain; 338 | Oidc.login({ 339 | oAuthid: profile.sub, 340 | username: profile.preferred_username, 341 | email: email, 342 | rolesEnabled: settings.rolesClaim && settings.rolesClaim.length !== 0, 343 | isAdmin: false, 344 | }, (err, user) => { 345 | if (err && err !== 'UserExists') { 346 | return callback(err); 347 | } 348 | authenticationController.onSuccessfulLogin(req, user.uid); 349 | callback(null, user); 350 | }); 351 | } 352 | } 353 | )); 354 | 355 | // If we are doing the update, strategies won't be the right object so 356 | if (strategies) { 357 | strategies.push({ 358 | name: constants.name, 359 | url: '/auth/' + constants.name, 360 | callbackURL: '/auth/' + constants.name + '/callback', 361 | icon: 'fa-openid', 362 | scope: ['openid', settings.emailClaim], 363 | checkState: false, 364 | }); 365 | } 366 | 367 | callback(null, strategies); 368 | }); 369 | }; 370 | 371 | Oidc.login = function (payload, callback) { 372 | async.waterfall([ 373 | // Lookup user by existing oauthid 374 | (callback) => Oidc.getUidByOAuthid(payload.oAuthid, callback), 375 | // Skip if we found the user in the pevious step or create the user 376 | function (uid, callback) { 377 | if (uid !== null) { 378 | // Existing user 379 | callback("UserExists", uid); 380 | } else { 381 | // New User 382 | if (!payload.email) { 383 | return callback(new Error('The email was missing from the user, we cannot log them in.')); 384 | } 385 | 386 | async.waterfall([ 387 | (callback) => User.getUidByEmail(payload.email, callback), 388 | function (uid, callback) { 389 | if (!uid) { 390 | User.create({ 391 | username: payload.username, 392 | fullname: payload.fullname ? payload.fullname : null, 393 | email: payload.email, 394 | }, callback); 395 | } else { 396 | callback("UserExists", uid); // Existing account -- merge 397 | } 398 | }, 399 | function (uid, callback) { 400 | // Save provider-specific information to the user 401 | User.setUserField(uid, constants.name + 'Id', payload.oAuthid); 402 | db.setObjectField(constants.name + 'Id:uid', payload.oAuthid, uid); 403 | callback(null, uid); 404 | }, 405 | ], callback); 406 | } 407 | }, 408 | // Get the users membership status to admins 409 | (uid, callback) => Groups.isMember(uid, 'administrators', (err, isMember) => { 410 | callback(err, uid, isMember); 411 | }), 412 | // If the plugin is configured to use roles, add or remove them from the admin group (if necessary) 413 | (uid, isMember, callback) => { 414 | if (payload.rolesEnabled) { 415 | if (payload.isAdmin === true && !isMember) { 416 | Groups.join('administrators', uid, (err) => { 417 | callback(err, uid); 418 | }); 419 | } else if (payload.isAdmin === false && isMember) { 420 | Groups.leave('administrators', uid, (err) => { 421 | callback(err, uid); 422 | }); 423 | } else { 424 | // Continue 425 | callback(null, uid); 426 | } 427 | } else { 428 | // Continue 429 | callback(null, uid); 430 | } 431 | }, 432 | ], function (err, uid) { 433 | if (err && err !== 'UserExists') { 434 | return callback(err); 435 | } 436 | callback(err, { 437 | uid: uid, 438 | }); 439 | }); 440 | }; 441 | 442 | Oidc.getUidByOAuthid = function (oAuthid, callback) { 443 | db.getObjectField(constants.name + 'Id:uid', oAuthid, (err, uid) => { 444 | if (err) { 445 | return callback(err); 446 | } 447 | callback(null, uid); 448 | }); 449 | }; 450 | 451 | Oidc.deleteUserData = function (data, callback) { 452 | async.waterfall([ 453 | async.apply(User.getUserField, data.uid, constants.name + 'Id'), 454 | (oAuthIdToDelete, next) => { 455 | db.deleteObjectField(constants.name + 'Id:uid', oAuthIdToDelete, next); 456 | }, 457 | ], (err) => { 458 | if (err) { 459 | winston.error('[sso-oauth] Could not remove OAuthId data for uid ' + data.uid + '. Error: ' + err); 460 | return callback(err); 461 | } 462 | 463 | callback(null, data); 464 | }); 465 | }; 466 | 467 | // If this filter is not there, the deleteUserData function will fail when getting the oauthId for deletion. 468 | Oidc.whitelistFields = function (params, callback) { 469 | params.whitelist.push(constants.name + 'Id'); 470 | callback(null, params); 471 | }; 472 | 473 | Oidc.bindMenuOption = function (header, callback) { 474 | winston.verbose('Binding menu option'); 475 | header.authentication.push({ 476 | route: constants.pluginSettingsURL.replace('/admin', ''), // They will add the /admin for us 477 | name: 'Sunbird SSO', 478 | }); 479 | 480 | callback(null, header); 481 | }; 482 | 483 | Oidc.redirectLogout = function (payload, callback) { 484 | const settings = constants.pluginSettings.getWrapper(); 485 | 486 | if (settings.logoutEndpoint) { 487 | winston.verbose('Changing logout to OpenID logout'); 488 | let separator; 489 | if (settings.logoutEndpoint.indexOf('?') === -1) { 490 | separator = '?'; 491 | } else { 492 | separator = '&'; 493 | } 494 | payload.next = settings.logoutEndpoint + separator + 'client_id=' + settings.clientId; 495 | } 496 | 497 | return callback(null, payload); 498 | }; 499 | 500 | module.exports = Oidc; 501 | })(module); --------------------------------------------------------------------------------