SSO authentication for SCM-Manager (3.x) via OAuth2 / OpenID Connect using the authorization code flow. Tested with Keycloak, but works with any spec compliant identity provider (IdP).
- Authorization code flow with a confidential client (client id + client secret) and PKCE (S256)
- OIDC discovery: all endpoints can be resolved automatically from the
.well-known/openid-configurationdocument of the IdP — alternatively all endpoints can be configured manually - Login button on the login page: "Login with <provider name>", in addition to the conventional username/password form
- User synchronization: users are created as external users on first login; display name and email are updated from the configured userinfo claims on every login
- Migration of existing users: an instance which already authenticates against LDAP (or any other source) can be switched over without losing permissions, ownership or memberships, as long as the IdP delivers the same user names
- Group synchronization: groups from the group claim are created as SCM groups if missing and the user is added to/removed from them on every login — groups are never deleted
- Roles from the access token (optional): read additional roles from a configurable JSON path
inside the access token, e.g. the Keycloak realm roles at
realm_access.roles - Administrator group: members of a configurable group get the global administrator permission assigned on login (and revoked again, if the group membership is gone)
- Force login (optional): unauthenticated browser requests are redirected to the IdP automatically, conventional login is disabled
- Full SSO logout (optional): RP-initiated logout at the IdP with
id_token_hintandpost_logout_redirect_uri, so the complete SSO session ends without a confirmation prompt and the browser returns to the SCM login page - Verified tokens: signature and claim verification of id token and access token against the JSON Web Key Set of the IdP, including a nonce per login
- The client secret is stored encrypted and is never returned by the API
The plugin is not part of the official plugin center and is therefore installed manually. It requires SCM-Manager 3.9.0 or newer.
-
Download
scm-oauth2-plugin.smpfrom the releases page -
Copy the file into the
pluginsdirectory of your SCM-Manager home, e.g.cp scm-oauth2-plugin.smp /var/lib/scm/plugins/
-
Restart SCM-Manager. The archive is unpacked during startup — afterwards the directory
plugins/scm-oauth2-plugin/exists and the.smpfile is gone, which is expected. -
Verify the installation in Administration → Plugins → Installed, or via REST:
curl -u "scmadmin:secret" http://localhost:8080/scm/api/v2/plugins/installed \ | grep -o '"name":"scm-oauth2-plugin","version":"[^"]*"'
Afterwards configure the plugin as described under Configuration.
The home directory is the one containing config/, repositories/ and plugins/. The reliable
way to find it is the log entry written on every startup:
grep "scm home directory" /var/log/scm/scm-manager.log
# using directory /var/lib/scm as scm home directoryTypical defaults, unless overridden by the environment variable SCM_HOME or the system property
scm.home:
| Installation | Home directory |
|---|---|
| Linux package / Docker | /var/lib/scm |
| macOS | ~/Library/Application Support/SCM-Manager |
| Windows | %APPDATA%\SCM-Manager |
Since the archive is unpacked, an old version has to be removed first, otherwise the previous plugin directory stays in place:
# stop SCM-Manager first
rm -rf /var/lib/scm/plugins/scm-oauth2-plugin
cp scm-oauth2-plugin.smp /var/lib/scm/plugins/
# start SCM-ManagerThe configuration is stored in config/oauth2.xml and survives an update. To uninstall, remove the
plugin directory and restart.
- The user clicks "Login with <provider>" (or is redirected by the force login filter) —
GET /api/v2/oauth2/auth?from=<original url> - The plugin creates a random
state(CSRF protection, valid once, 10 minutes), a PKCE verifier and a nonce, stores thestatein anHttpOnlycookie of this browser and redirects to the authorization endpoint of the IdP (withcode_challenge, if the IdP supports S256) - After authentication the IdP redirects back to the callback
GET /api/v2/oauth2/auth/callback?code=...&state=... - The plugin checks that the
statebelongs to the state cookie of this browser, exchanges the authorization code for tokens at the token endpoint (server-to-server,client_secret_post, withcode_verifier), verifies the id token (signature, issuer, audience, expiry, nonce) and fetches the user claims from the userinfo endpoint - The user is created/updated/migrated, groups and permissions are synchronized, the id token is kept in memory for a later SSO logout and an SCM access token cookie is issued
- The browser is redirected to the originally requested url
Important: the plugin reads the claims (username, display name, email, groups) from the userinfo endpoint — not from the access or id token. Make sure your IdP adds the claims to the userinfo response (in Keycloak: protocol mapper setting Add to userinfo). The only exception is the optional role import from the access token.
On logout SCM-Manager redirects the browser to the end session endpoint of the IdP with:
id_token_hint— the id token of the login (kept in memory per user, consumed on logout), so the IdP can end the matching SSO session without asking for confirmationclient_idpost_logout_redirect_uri— the SCM-Manager base url, so the browser returns to the SCM login page afterwards; must be registered at the IdP client as a valid post logout redirect uri
If the id token is not available (e.g. after a server restart), the logout still works — the IdP may then show a confirmation page.
Administration → Settings → OAuth2 / OIDC, or via REST (see below).
| Setting | Key | Default | Description |
|---|---|---|---|
| Enabled | enabled |
false |
Master switch for the plugin |
| Force login | forceLogin |
false |
Redirect unauthenticated browsers to the IdP automatically; conventional login is no longer possible |
| Full SSO logout | ssoLogout |
false |
RP-initiated logout at the IdP (see above) |
| Take over local accounts | migrateLocalUsers |
false |
Allows the IdP to take over accounts which still have a local password, see Migration of existing users |
| Provider name | providerName |
— | Display name of the IdP, shown as "Login with <provider name>". Required if the plugin is enabled |
| OIDC Discovery URL | discoveryUrl |
— | Issuer url or complete url of the .well-known/openid-configuration document. If set, all endpoints below are resolved automatically (cached for one hour) and the manual entries are ignored |
| Authorization endpoint | authorizationUrl |
— | Manual endpoint configuration (only used without discovery url) |
| Token endpoint | tokenUrl |
— | " |
| Userinfo endpoint | userinfoUrl |
— | " |
| End session endpoint | endSessionUrl |
— | ", optional; only needed for ssoLogout |
| JWKS endpoint | jwksUrl |
— | ", public keys used to verify the signatures of id token and access token. Without it (and without a discovery url) no token is verified and none is used |
| Client ID | clientId |
— | Client registered at the IdP |
| Client secret | clientSecret |
— | Secret of the (confidential) client. Stored encrypted, never returned by GET; send an empty value on update to keep the stored secret (the read-only flag clientSecretSet tells whether one is stored) |
| Scopes | scopes |
openid profile email |
Space separated list of requested scopes |
| Username claim | usernameAttribute |
preferred_username |
Userinfo claim used as SCM username (falls back to sub) |
| Display name claim | displayNameAttribute |
name |
Userinfo claim for the display name |
| Mail claim | mailAttribute |
email |
Userinfo claim for the email address (invalid addresses are ignored) |
| Group claim | groupAttribute |
groups |
Userinfo claim for group memberships (string or array of strings) |
| Import realm roles from access token | importRealmRoles |
false |
Additionally read roles from the access token, see below |
| JSON path of the roles | realmRolesPath |
realm_access.roles |
Path to the roles inside the access token, field names separated by dots |
| Administrator group | adminGroup |
scmadmin |
Members of this group get the global administrator permission on login, non-members get it revoked. Empty = permissions are never touched |
Register the following redirect uri at the IdP client:
<scm-base-url>/api/v2/oauth2/auth/callback
e.g. http://scm-server.localhost:8080/scm/api/v2/oauth2/auth/callback. For the SSO logout the
SCM base url (e.g. http://scm-server.localhost:8080/scm) has to be registered as valid post
logout redirect uri additionally.
curl -u "scmadmin:secret" -H "Content-Type: application/vnd.scmm-oauth2Config+json;v=2" -XPUT -d '{
"providerName": "Sonia",
"discoveryUrl": "https://id.dev.sonia.de/realms/sonia",
"clientId": "scm-server",
"clientSecret": "<secret>",
"scopes": "openid profile email",
"usernameAttribute": "preferred_username",
"displayNameAttribute": "name",
"mailAttribute": "email",
"groupAttribute": "groups",
"adminGroup": "scmadmin",
"importRealmRoles": false,
"realmRolesPath": "realm_access.roles",
"migrateLocalUsers": false,
"forceLogin": false,
"ssoLogout": true,
"enabled": true
}' http://scm-server.localhost:8080/scm/api/v2/oauth2/configurationGET on the same url returns the current configuration. Setting enabled: true without a
providerName is rejected with 400 Bad Request.
Users are identified by the username claim, so an instance which already authenticates against another source (e.g. LDAP) can be switched to OAuth2 without losing anything, as long as the identity provider delivers the same user names. On the first OAuth2 login the existing account is reused, which keeps everything that is bound to the user name:
- assigned permissions and repository ownership
- group memberships (existing memberships are never removed on the first login, because the plugin only removes a user from groups which it added itself in a previous login)
- api keys and public keys
Attributes which the identity provider does not deliver keep their stored
value — a missing mail claim does not wipe the mail address of a migrated user.
The stored active flag is preserved as well, so a deactivated account is not
reactivated by a login.
Accounts which can still be used for a local password login are not taken
over by default: the login is rejected with a warning in the log, because
otherwise a user of the identity provider could seize a local account (e.g. the
initial scmadmin) just by using its name. Users of other external
authentications (LDAP, CAS) have no local password and are migrated silently.
To migrate local accounts as well, enable Take over local accounts
(migrateLocalUsers) — their local password is removed in that case, because
the account is authenticated by the identity provider from then on.
To check upfront how an existing user is stored:
curl -u "scmadmin:secret" https://scm.example.com/scm/api/v2/users/<username> | grep -o '"external":[a-z]*'On every login the group claim is compared with the state of the previous login:
- Groups from the claim which do not exist in SCM-Manager yet are created (internal groups, description "Synchronized by scm-oauth2-plugin") with the user as first member
- The user is added as member to existing groups from the claim
- The user is removed from groups which were synchronized on a previous login but are no longer part of the claim
- Groups are never deleted, even if they become empty
- Memberships which were assigned manually in other groups are not touched — only groups that came from the claim of this user are considered for removal
- External groups are skipped, their members are not managed by SCM-Manager
- A failure while synchronizing a group never prevents the login itself
Characters which SCM-Manager does not allow in a name (/ : ? # ; & = % \, a leading @ and
leading/trailing whitespace) are replaced by an underscore, so no group is lost. A Keycloak
group mapper with Full group path enabled therefore results in:
| Claim | SCM group |
|---|---|
/developers |
_developers |
/team/backend |
_team_backend |
SCM RZ-intern |
SCM RZ-intern (unchanged, internal blanks are allowed) |
The sanitized name is used consistently — for the created group, for the membership and for the
authorization — so permissions granted to the group really apply. Keep that in mind for the
administrator group: if the identity provider sends /scmadmin, the setting has to be
_scmadmin. Two claim values which only differ in invalid characters (a/b and a:b) end up in
the same SCM group.
Additionally the effective groups of the user are resolved directly from the claim at authorization time (group resolver), so permissions assigned to a group name work even before the group entity exists.
Some identity providers deliver roles only in the access token and not in the
userinfo response — Keycloak realm roles are the typical example. With
Import realm roles from access token (importRealmRoles) these roles are read
from the token and added to the groups of the userinfo response. The
location is configured as a dot separated path (realmRolesPath):
| Path | Reads |
|---|---|
realm_access.roles |
Keycloak realm roles (default) |
resource_access.<client-id>.roles |
Keycloak client roles of that client |
groups |
a top level claim of the token |
The path may also contain array indexes (realm_access.roles.0). Both arrays
and single string values are accepted. If the access token is not a JWT (some
providers issue opaque tokens) or the path does not exist, no roles are
imported and the login continues normally.
The signature of the access token is not verified for this, which is safe because the token was received directly from the token endpoint of the configured identity provider over TLS — the same trust level as the userinfo response.
Note: every imported role becomes an SCM group (see below). Keycloak realm roles often include many technical roles (
offline_access,uma_authorization,default-roles-<realm>, …), so the group list can grow considerably. If only a few roles are relevant, a client role mapper withresource_access.<client-id>.rolesis the more targeted choice.
If the group claim contains the configured administrator group (default scmadmin), the user
gets the global administrator permission (*) assigned; if the group is missing, a previously
assigned permission is revoked on the next login. With a configured admin group the claim is the
single source of truth for this one permission — manually granting it to an OAuth2 user without
the group does not survive the next login. Leave the field empty to disable this mechanism.
The plugin implements the current recommendations for the authorization code flow (RFC 6749/9700):
- the
stateis bound to the browser which started the flow via anHttpOnly,SameSite=Laxcookie (Secureas soon as the instance runs on HTTPS), so an authorization code of a foreign session cannot be replayed into somebody else's browser - PKCE with S256; the verifier never leaves the server. It is omitted only if the discovery document explicitly advertises no support for it
- the id token is verified cryptographically: signature against the JSON Web Key Set of the IdP
(RS/PS/ES family) or against the client secret (HS family), plus
iss,aud/azp, expiry and thenonceof this very login.alg: noneand unknown algorithms are rejected, and the algorithm determines which kind of key is accepted (no algorithm confusion). An id token which is present but invalid aborts the login - a fresh nonce is sent with every authorization request, so an id token of another session cannot be replayed
- roles are only imported from a verified access token; an opaque token, an invalid signature or a missing key set means no roles instead of unverified ones
- the client secret is stored encrypted and is write-only in the API
- only absolute
http/httpsurls are accepted as endpoints - the forced login only lets requests pass whose access token cookie can actually be validated (signature, expiry)
- error details of the identity provider are written to the log only, never into the response
Two points to keep in mind when operating the plugin:
-
Whoever controls the group names in the IdP controls the permissions in SCM-Manager — the claim decides about group memberships and, via the administrator group, about global admin rights. Groups/roles should therefore be maintained by IdP administrators only (Keycloak client roles are a good fit).
-
The permission
configuration:write:oauth2has to be treated like global admin: whoever may change the configuration can point the endpoints at a different host and thereby both receive the client secret and take over the authentication completely. -
A key source is required for the token verification. With a discovery url it is taken from the document (
jwks_uri) automatically. If the endpoints are configured manually, configurejwksUrlas well — otherwise the plugin logs a warning, discards the id token (the SSO logout then works withoutid_token_hint) and imports no roles from the access token. Note the difference: a missing key set url only costs the id token, whereas a configured key set which cannot be fetched or does not contain the key of the token makes the login fail — an id token that is present has to be verifiable (fail closed). The key set is cached for one hour and re-fetched when a token names an unknown key, so a key rotation at the IdP needs no action.
Run the instance behind TLS — the client secret is transmitted in the body when the configuration
is saved, and the session cookie is only marked Secure on an HTTPS connection.
scm-server.json contains an export of a working sample client for the setup
described above (SCM-Manager reachable at http://scm-server.localhost:8080/scm). Import it in
the Keycloak admin console via Clients → Import client and generate a client secret afterwards —
the file contains the placeholder GENERATE_YOUR_OWN_SECRET_IN_KEYCLOAK instead of a real secret.
The important settings of the sample client:
- Confidential client:
publicClient: false,clientAuthenticatorType: client-secret, only the standard flow (authorization code) is enabled — no implicit flow, no direct access grants, no service account - Redirect uri:
/scm/api/v2/oauth2/auth/callback(relative to the root urlhttp://scm-server.localhost:8080) - Post logout redirect uri (
attributes."post.logout.redirect.uris"):http://scm-server.localhost:8080/scm— required for the full SSO logout (adjust it to your base url; avoid wildcards in production) - Logout confirmation disabled (
attributes."logout.confirmation.enabled": "false"), so the RP-initiated logout runs without user interaction - Groups claim via client roles: the protocol mapper
client roles(oidc-usermodel-client-role-mapper) maps the client roles ofscm-serverto the claimgroups— with"userinfo.token.claim": "true", which is essential because the plugin reads the claims from the userinfo endpoint - Scopes:
profileis a default scope,emailan optional one — the plugin requestsopenid profile email, so the email claim is delivered if the email scope is granted
With this mapper setup, SCM groups are managed as Keycloak client roles of the scm-server
client:
- Keycloak → Clients →
scm-server→ Roles → create role, e.g.scmadminordevelopers - Assign the role to users (Users → Role mapping → Assign role → filter by client)
- On the next SCM login the role shows up in the
groupsclaim, the corresponding SCM group is created/updated and — in case ofscmadmin— the global administrator permission is assigned
Alternatively a Keycloak Group Membership mapper (claim groups, Add to userinfo enabled,
Full group path disabled) can be used to map real Keycloak groups instead of client roles.
The plugin can be compiled and packaged with the following tasks:
- clean -
gradle clean- deletes the build directory - run -
gradle run- starts an SCM-Manager with the plugin pre-installed and with livereload for the ui - build -
gradle build- executes all checks, tests and builds the smp inclusive javadoc and source jar - test -
gradle test- run all java tests - ui-test -
gradle ui-test- run all ui tests - check -
gradle check- executes all registered checks and tests (java and ui) - fix -
gradle fix- fixes all fixable findings of the check task - smp -
gradle smp- Builds the smp file, without the execution of checks and tests - javadoc -
gradle javadoc- generates the api documentation belowbuild/docs/javadoc
Gradle has no profiles like maven; the build evaluates the project property stage instead.
With -Pstage=release the api documentation is written to docs/javadoc next to
the sources instead of into the build directory, so it is committed together with the release:
# regenerate only the documentation
./gradlew javadoc -Pstage=release
# complete release build including the committed documentation
./gradlew build -Pstage=releaseThe directory is emptied before it is regenerated, so pages of removed classes do not stay behind.
Without the property everything stays in the build directory and docs/javadoc is untouched.
For the development and testing the run task of the plugin can be used:
- run -
gradle run- starts scm-manager with the plugin pre-installed.
If the plugin was started with gradle run, the default browser of the os should be automatically opened.
If the browser does not start automatically, start it manually and go to http://localhost:8081/scm.
In this mode each change to web files (src/main/js or src/main/webapp), should trigger reload of the browser with the made changes.
A quick look at the files and directories you'll see in an SCM-Manager project.
.
├── node_modules/
├── src/
| ├── main/
| | ├── java/
| | ├── js/
| | └── resources/
| └── test/
| ├── java/
| └── resources/
├── .editorconfig
├── .gitignore
├── build.gradle
├── CHANGELOG.md
├── gradle.properties
├── gradlew
├── LICENSE.txt
├── package.json
├── README.md
├── scm-server.json
├── settings.gradle
├── tsconfig.json
└── yarn.lock
-
node_modules/: This directory contains all modules of code that your project depends on (npm packages) are automatically installed. -
src/: This directory will contain all code related to what you see or not.srcis a convention for “source code”.main/java/: This directory contains the Java code.js/: This directory contains the JavaScript code for the web ui, inclusive unit tests: suffixed with.test.tsresources/: This directory contains the classpath resources.
test/java/: This directory contains the Java unit tests.resources/: This directory contains classpath resources for unit tests.
-
.editorconfig: This is a configuration file for your editor using EditorConfig. The file specifies a style that IDEs use for code. -
.gitignore: This file tells git which files it should not track / not maintain a version history for. -
build.gradle: Gradle build configuration, which also includes things like metadata. -
CHANGELOG.md: All notable changes to this project will be documented in this file. -
gradle.properties: Defines the module version. -
gradlew: Bundled gradle wrapper if you don't have gradle installed. -
LICENSE.txt: This project is licensed under AGPLv3. -
package.json: Here you can find the dependency/build configuration and dependencies for the frontend. -
README.md: This file, containing useful reference information about the project. A German version is available inREADME_de.md. -
scm-server.json: Export of the Keycloak sample client (see Keycloak example). -
settings.gradle: Gradle settings configuration. -
tsconfig.jsonThis is the typescript configuration file. -
yarn.lock: This is the ui dependency configuration.
Below docs/ you will find the generated api documentation as well as four documents (in German)
which describe the plugin from a data protection and information security point of view:
| Document | Content |
|---|---|
| PROZESS_BESCHREIBUNG.md | Processing operations, data categories, storage locations, retention and recipients — basis for a record of processing activities (art. 30 GDPR) |
| COMPLIANCE_BERICHT.md | Assessment against GDPR and the NIS2 risk management measures (art. 21(2)), including implemented measures, findings and residual risks |
| DATENSCHUTZ_FOLGEABSCHAETZUNG.md | Threshold analysis and data protection impact assessment (art. 35 GDPR) |
| SOURCECODE_STATISTIK.md | Size, test coverage, documentation ratio and dependencies |
| docs/javadoc | Api documentation, regenerated with ./gradlew javadoc -Pstage=release |
The documents describe the software; the organisational parts (legal basis, retention periods, responsibilities) have to be completed by the operator of the instance.
Looking for more guidance? Full documentation lives on the SCM-Manager homepage or the dedicated pages for the plugins.