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

@@ -209,22 +209,23 @@ 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) |
| | `CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR` | **no default**, `email` | where to find the email address in the JSON from the user profile URL. (no default value) | | | `CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR` | **no default**, `email` | where to find the email address in the JSON from the user profile URL. (no default value) |
| | `CMD_OAUTH2_USER_PROFILE_ID_ATTR` | **no default**, `user_uuid` | where to find the dedicated user ID (optional, overrides `CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR`) | | | `CMD_OAUTH2_USER_PROFILE_ID_ATTR` | **no default**, `user_uuid` | where to find the dedicated user ID (optional, overrides `CMD_OAUTH2_USER_PROFILE_USERNAME_ATTR`) |
| | `CMD_OAUTH2_TOKEN_URL` | **no default**, `https://example.com` | sometimes called token endpoint, please refer to the documentation of your OAuth2 provider (no default value) | | | `CMD_OAUTH2_TOKEN_URL` | **no default**, `https://example.com` | sometimes called token endpoint, please refer to the documentation of your OAuth2 provider (no default value) |
| | `CMD_OAUTH2_AUTHORIZATION_URL` | **no default**, `https://example.com` | authorization URL of your provider, please refer to the documentation of your OAuth2 provider (no default value) | | | `CMD_OAUTH2_AUTHORIZATION_URL` | **no default**, `https://example.com` | authorization URL of your provider, please refer to the documentation of your OAuth2 provider (no default value) |
| | `CMD_OAUTH2_CLIENT_ID` | **no default**, `afae02fckafd...` | you will get this from your OAuth2 provider when you register HedgeDoc as OAuth2-client, (no default value) | | | `CMD_OAUTH2_CLIENT_ID` | **no default**, `afae02fckafd...` | you will get this from your OAuth2 provider when you register HedgeDoc as OAuth2-client, (no default value) |
| | `CMD_OAUTH2_CLIENT_SECRET` | **no default**, `afae02fckafd...` | you will get this from your OAuth2 provider when you register HedgeDoc as OAuth2-client, (no default value) | | | `CMD_OAUTH2_CLIENT_SECRET` | **no default**, `afae02fckafd...` | you will get this from your OAuth2 provider when you register HedgeDoc as OAuth2-client, (no default value) |
| | `CMD_OAUTH2_PROVIDERNAME` | **no default**, `My institution` | Optional name to be displayed at login form indicating the oAuth2 provider | | | `CMD_OAUTH2_PROVIDERNAME` | **no default**, `My institution` | Optional name to be displayed at login form indicating the oAuth2 provider |
| | `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