fix(auth): add state parameters and PKCE support

Only the OAuth2 auth strategy was using the state parameter,
which should be used as described in the RFC. The other
auth strategies such as GitHub, GitLab or Google were lacking
the state parameter.
This change adds the required state parameter as well as
enabling PKCE support on providers where it's possible.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2025-12-03 21:07:56 +01:00
parent 53f2ada7a3
commit 35f36fccba
12 changed files with 53 additions and 24 deletions

View File

@@ -106,6 +106,19 @@
"email": "change or delete this: attribute map for `email` (default: NameID)" "email": "change or delete this: attribute map for `email` (default: NameID)"
} }
}, },
"oauth2": {
"baseURL": "https://auth.example.com/",
"userProfileURL": "https://auth.example.com/oauth2/userinfo/",
"tokenURL": "https://auth.example.com/oauth2/token/",
"authorizationURL": "https://auth.example.com/oauth2/authorize/",
"clientID": "change-this-id",
"clientSecret": "change-this-secret",
"scope": "openid profile user",
"userProfileUsernameAttr": "preferred_username",
"userProfileEmailAttr": "email",
"userProfileDisplayNameAttr": "name",
"pkce": true
},
"imgur": { "imgur": {
"clientID": "change this" "clientID": "change this"
}, },

View File

@@ -210,8 +210,8 @@ these are rarely used for various reasons.
### OAuth2 Login ### OAuth2 Login
| config file | environment | **default** and example value | description | | config file | environment | **default** and example value | description |
|-------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------|---------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `oauth2` | | `{baseURL: ..., userProfileURL: ..., userProfileUsernameAttr: ..., userProfileDisplayNameAttr: ..., userProfileEmailAttr: ..., tokenURL: ..., authorizationURL: ..., clientID: ..., clientSecret: ..., scope: ...}` | An object detailing your OAuth2 provider. Refer to the [Mattermost](guides/auth/mattermost-self-hosted.md) or [Nextcloud](guides/auth/nextcloud.md) examples for more details! | | `oauth2` | | `{baseURL: ..., userProfileURL: ..., userProfileUsernameAttr: ..., userProfileDisplayNameAttr: ..., userProfileEmailAttr: ..., tokenURL: ..., authorizationURL: ..., clientID: ..., clientSecret: ..., scope: ..., pkce: ...}` | An object detailing your OAuth2 provider. Refer to the [Mattermost](guides/auth/mattermost-self-hosted.md) or [Nextcloud](guides/auth/nextcloud.md) examples for more details! |
| | `CMD_OAUTH2_USER_PROFILE_URL` | **no default**, `https://example.com` | Where to retrieve information about a user after successful login. Needs to output JSON. (no default value) Refer to the [Mattermost](guides/auth/mattermost-self-hosted.md) or [Nextcloud](guides/auth/nextcloud.md) examples for more details on all of the `CMD_OAUTH2...` options. | | | `CMD_OAUTH2_USER_PROFILE_URL` | **no default**, `https://example.com` | Where to retrieve information about a user after successful login. Needs to output JSON. (no default value) Refer to the [Mattermost](guides/auth/mattermost-self-hosted.md) or [Nextcloud](guides/auth/nextcloud.md) examples for more details on all of the `CMD_OAUTH2...` options. |
| | `CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR` | **no default**, `name` | where to find the username in the JSON from the user profile URL. (no default value) | | | `CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR` | **no default**, `name` | where to find the username in the JSON from the user profile URL. (no default value) |
| | `CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR` | **no default**, `display-name` | where to find the display-name in the JSON from the user profile URL. (no default value) | | | `CMD_OAUTH2_USER_PROFILE_DISPLAY_NAME_ATTR` | **no default**, `display-name` | where to find the display-name in the JSON from the user profile URL. (no default value) |
@@ -225,6 +225,7 @@ these are rarely used for various reasons.
| | `CMD_OAUTH2_SCOPE` | **no default**, `openid email profile` | Scope to request for OIDC (OpenID Connect) providers. | | | `CMD_OAUTH2_SCOPE` | **no default**, `openid email profile` | Scope to request for OIDC (OpenID Connect) providers. |
| | `CMD_OAUTH2_ROLES_CLAIM` | **no default**, `roles` | ID token claim, which is supposed to provide an array of strings of roles | | | `CMD_OAUTH2_ROLES_CLAIM` | **no default**, `roles` | ID token claim, which is supposed to provide an array of strings of roles |
| | `CMD_OAUTH2_ACCESS_ROLE` | **no default**, `role/hedgedoc` | The role which should be included in the ID token roles claim to grant access | | | `CMD_OAUTH2_ACCESS_ROLE` | **no default**, `role/hedgedoc` | The role which should be included in the ID token roles claim to grant access |
| | `CMD_OAUTH2_PKCE` | **`false`**, `true` | Whether to use PKCE auth. Defaults to false since not every OAuth2 provider supports it. |
!!! info !!! info
If you are using a [CA not trusted by Node.js](https://github.com/nodejs/node/issues/4175) (like Let's Encrypt e.g) for If you are using a [CA not trusted by Node.js](https://github.com/nodejs/node/issues/4175) (like Let's Encrypt e.g) for

View File

@@ -104,7 +104,8 @@ module.exports = {
tokenURL: undefined, tokenURL: undefined,
clientID: undefined, clientID: undefined,
clientSecret: undefined, clientSecret: undefined,
scope: undefined scope: undefined,
pkce: false
}, },
facebook: { facebook: {
clientID: undefined, clientID: undefined,

View File

@@ -115,7 +115,8 @@ module.exports = {
clientSecret: process.env.CMD_OAUTH2_CLIENT_SECRET, clientSecret: process.env.CMD_OAUTH2_CLIENT_SECRET,
scope: process.env.CMD_OAUTH2_SCOPE, scope: process.env.CMD_OAUTH2_SCOPE,
rolesClaim: process.env.CMD_OAUTH2_ROLES_CLAIM, rolesClaim: process.env.CMD_OAUTH2_ROLES_CLAIM,
accessRole: process.env.CMD_OAUTH2_ACCESS_ROLE accessRole: process.env.CMD_OAUTH2_ACCESS_ROLE,
pkce: toBooleanConfig(process.env.CMD_OAUTH2_PKCE)
}, },
dropbox: { dropbox: {
clientID: process.env.CMD_DROPBOX_CLIENTID, clientID: process.env.CMD_DROPBOX_CLIENTID,

View File

@@ -12,7 +12,9 @@ passport.use(new DropboxStrategy({
apiVersion: '2', apiVersion: '2',
clientID: config.dropbox.clientID, clientID: config.dropbox.clientID,
clientSecret: config.dropbox.clientSecret, clientSecret: config.dropbox.clientSecret,
callbackURL: config.serverURL + '/auth/dropbox/callback' callbackURL: config.serverURL + '/auth/dropbox/callback',
state: true,
pkce: true
}, passportGeneralCallback)) }, passportGeneralCallback))
dropboxAuth.get('/auth/dropbox', function (req, res, next) { dropboxAuth.get('/auth/dropbox', function (req, res, next) {

View File

@@ -12,7 +12,9 @@ const facebookAuth = module.exports = Router()
passport.use(new FacebookStrategy({ passport.use(new FacebookStrategy({
clientID: config.facebook.clientID, clientID: config.facebook.clientID,
clientSecret: config.facebook.clientSecret, clientSecret: config.facebook.clientSecret,
callbackURL: config.serverURL + '/auth/facebook/callback' callbackURL: config.serverURL + '/auth/facebook/callback',
state: true,
pkce: true
}, passportGeneralCallback)) }, passportGeneralCallback))
facebookAuth.get('/auth/facebook', function (req, res, next) { facebookAuth.get('/auth/facebook', function (req, res, next) {

View File

@@ -12,7 +12,9 @@ const githubAuth = module.exports = Router()
passport.use(new GithubStrategy({ passport.use(new GithubStrategy({
clientID: config.github.clientID, clientID: config.github.clientID,
clientSecret: config.github.clientSecret, clientSecret: config.github.clientSecret,
callbackURL: config.serverURL + '/auth/github/callback' callbackURL: config.serverURL + '/auth/github/callback',
pkce: true,
state: true
}, passportGeneralCallback)) }, passportGeneralCallback))
githubAuth.get('/auth/github', function (req, res, next) { githubAuth.get('/auth/github', function (req, res, next) {

View File

@@ -14,7 +14,9 @@ passport.use(new GitlabStrategy({
clientID: config.gitlab.clientID, clientID: config.gitlab.clientID,
clientSecret: config.gitlab.clientSecret, clientSecret: config.gitlab.clientSecret,
scope: config.gitlab.scope, scope: config.gitlab.scope,
callbackURL: config.serverURL + '/auth/gitlab/callback' callbackURL: config.serverURL + '/auth/gitlab/callback',
pkce: true,
state: true
}, passportGeneralCallback)) }, passportGeneralCallback))
gitlabAuth.get('/auth/gitlab', function (req, res, next) { gitlabAuth.get('/auth/gitlab', function (req, res, next) {

View File

@@ -12,7 +12,9 @@ passport.use(new GoogleStrategy({
clientID: config.google.clientID, clientID: config.google.clientID,
clientSecret: config.google.clientSecret, clientSecret: config.google.clientSecret,
callbackURL: config.serverURL + '/auth/google/callback', callbackURL: config.serverURL + '/auth/google/callback',
userProfileURL: 'https://www.googleapis.com/oauth2/v3/userinfo' userProfileURL: 'https://www.googleapis.com/oauth2/v3/userinfo',
pkce: true,
state: true
}, passportGeneralCallback)) }, passportGeneralCallback))
googleAuth.get('/auth/google', function (req, res, next) { googleAuth.get('/auth/google', function (req, res, next) {

View File

@@ -16,7 +16,8 @@ const mattermostStrategy = new OAuthStrategy({
tokenURL: config.mattermost.baseURL + '/oauth/access_token', tokenURL: config.mattermost.baseURL + '/oauth/access_token',
clientID: config.mattermost.clientID, clientID: config.mattermost.clientID,
clientSecret: config.mattermost.clientSecret, clientSecret: config.mattermost.clientSecret,
callbackURL: config.serverURL + '/auth/mattermost/callback' callbackURL: config.serverURL + '/auth/mattermost/callback',
state: true
}, passportGeneralCallback) }, passportGeneralCallback)
mattermostStrategy.userProfile = (accessToken, done) => { mattermostStrategy.userProfile = (accessToken, done) => {

View File

@@ -138,6 +138,7 @@ passport.use(new OAuth2CustomStrategy({
callbackURL: config.serverURL + '/auth/oauth2/callback', callbackURL: config.serverURL + '/auth/oauth2/callback',
userProfileURL: config.oauth2.userProfileURL, userProfileURL: config.oauth2.userProfileURL,
scope: config.oauth2.scope, scope: config.oauth2.scope,
pkce: config.oauth2.pkce,
state: true state: true
}, passportGeneralCallback)) }, passportGeneralCallback))

View File

@@ -16,6 +16,7 @@
- Force kill the server after a timeout when waiting for the realtime server to close connections on shutdown - Force kill the server after a timeout when waiting for the realtime server to close connections on shutdown
- Secure iframes with `credentialless` and `sandbox` attributes - Secure iframes with `credentialless` and `sandbox` attributes
- Fix regexes for `[time=...]`, `[name=...]` and `[color=...]` shortcodes in lists - Fix regexes for `[time=...]`, `[name=...]` and `[color=...]` shortcodes in lists
- Use `state` parameter for OAuth2 flows and PKCE where applicable
## <i class="fa fa-tag"></i> 1.10.3 <i class="fa fa-calendar-o"></i> 2025-04-09 ## <i class="fa fa-tag"></i> 1.10.3 <i class="fa fa-calendar-o"></i> 2025-04-09