diff --git a/build.sbt b/build.sbt
index 01ebd14..9498853 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,4 +1,4 @@
-import com.typesafe.sbt.license.{LicenseInfo, DepModuleInfo}
+import com.typesafe.sbt.license.{DepModuleInfo, LicenseInfo}
import com.typesafe.sbt.pgp.PgpKeys._
val Organization = "io.github.gitbucket"
@@ -55,6 +55,7 @@
"com.enragedginger" %% "akka-quartz-scheduler" % "1.6.1-akka-2.5.x" exclude("c3p0","c3p0"),
"net.coobird" % "thumbnailator" % "0.4.8",
"com.github.zafarkhaja" % "java-semver" % "0.9.0",
+ "com.nimbusds" % "oauth2-oidc-sdk" % "5.45",
"org.eclipse.jetty" % "jetty-webapp" % JettyVersion % "provided",
"javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
"junit" % "junit" % "4.12" % "test",
@@ -127,8 +128,8 @@
val executableKey = TaskKey[File]("executable")
executableKey := {
- import java.util.jar.{ Manifest => JarManifest }
- import java.util.jar.Attributes.{ Name => AttrName }
+ import java.util.jar.Attributes.{Name => AttrName}
+ import java.util.jar.{Manifest => JarManifest}
val workDir = Keys.target.value / "executable"
val warName = Keys.name.value + ".war"
diff --git a/src/main/resources/update/gitbucket-core_4.21.xml b/src/main/resources/update/gitbucket-core_4.21.xml
index 8cca0d1..c97c0e4 100644
--- a/src/main/resources/update/gitbucket-core_4.21.xml
+++ b/src/main/resources/update/gitbucket-core_4.21.xml
@@ -28,4 +28,12 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/scala/gitbucket/core/controller/IndexController.scala b/src/main/scala/gitbucket/core/controller/IndexController.scala
index ae155e9..484dc8f 100644
--- a/src/main/scala/gitbucket/core/controller/IndexController.scala
+++ b/src/main/scala/gitbucket/core/controller/IndexController.scala
@@ -1,23 +1,38 @@
package gitbucket.core.controller
+import java.net.URI
+
+import com.nimbusds.oauth2.sdk.id.State
+import com.nimbusds.openid.connect.sdk.Nonce
import gitbucket.core.helper.xml
import gitbucket.core.model.Account
import gitbucket.core.service._
import gitbucket.core.util.Implicits._
import gitbucket.core.util.SyntaxSugars._
import gitbucket.core.util.{Keys, LDAPUtil, ReferrerAuthenticator, UsersAuthenticator}
-import org.scalatra.forms._
import org.scalatra.Ok
+import org.scalatra.forms._
class IndexController extends IndexControllerBase
- with RepositoryService with ActivityService with AccountService with RepositorySearchService with IssuesService
- with UsersAuthenticator with ReferrerAuthenticator
+ with RepositoryService
+ with ActivityService
+ with AccountService
+ with RepositorySearchService
+ with IssuesService
+ with UsersAuthenticator
+ with ReferrerAuthenticator
+ with OpenIDConnectService
trait IndexControllerBase extends ControllerBase {
- self: RepositoryService with ActivityService with AccountService with RepositorySearchService
- with UsersAuthenticator with ReferrerAuthenticator =>
+ self: RepositoryService
+ with ActivityService
+ with AccountService
+ with RepositorySearchService
+ with UsersAuthenticator
+ with ReferrerAuthenticator
+ with OpenIDConnectService =>
case class SignInForm(userName: String, password: String, hash: Option[String])
@@ -55,13 +70,61 @@
post("/signin", signinForm){ form =>
authenticate(context.settings, form.userName, form.password) match {
- case Some(account) => signin(account, form.hash)
- case None => {
+ case Some(account) =>
+ flash.get(Keys.Flash.Redirect) match {
+ case Some(redirectUrl: String) => signin(account, redirectUrl + form.hash.getOrElse(""))
+ case _ => signin(account)
+ }
+ case None =>
flash += "userName" -> form.userName
flash += "password" -> form.password
flash += "error" -> "Sorry, your Username and/or Password is incorrect. Please try again."
redirect("/signin")
+ }
+ }
+
+ /**
+ * Initiate an OpenID Connect authentication request.
+ */
+ post("/signin/oidc") {
+ context.settings.oidc.map { oidc =>
+ val redirectURI = new URI(s"$baseUrl/signin/oidc")
+ val authenticationRequest = createOIDCAuthenticationRequest(oidc.issuer, oidc.clientID, redirectURI)
+ session.setAttribute(Keys.Session.OidcState, authenticationRequest.getState)
+ session.setAttribute(Keys.Session.OidcNonce, authenticationRequest.getNonce)
+ session.setAttribute(Keys.Session.OidcRedirectBackURI,
+ flash.get(Keys.Flash.Redirect) match {
+ case Some(redirectBackURI: String) => redirectBackURI + params.getOrElse("hash", "")
+ case _ => "/"
+ })
+ redirect(authenticationRequest.toURI.toString)
+ } getOrElse {
+ NotFound()
+ }
+ }
+
+ /**
+ * Handle an OpenID Connect authentication response.
+ */
+ get("/signin/oidc") {
+ context.settings.oidc.map { oidc =>
+ val redirectURI = new URI(s"$baseUrl/signin/oidc")
+ Seq(Keys.Session.OidcState, Keys.Session.OidcNonce, Keys.Session.OidcRedirectBackURI).map(session.get(_)) match {
+ case Seq(Some(state: State), Some(nonce: Nonce), Some(redirectBackURI: String)) =>
+ authenticate(params, redirectURI, state, nonce, oidc) map { account =>
+ signin(account, redirectBackURI)
+ } orElse {
+ flash += "error" -> "Sorry, authentication failed. Please try again."
+ session.invalidate()
+ redirect("/signin")
+ }
+ case _ =>
+ flash += "error" -> "Sorry, something wrong. Please try again."
+ session.invalidate()
+ redirect("/signin")
}
+ } getOrElse {
+ NotFound()
}
}
@@ -85,9 +148,9 @@
}
/**
- * Set account information into HttpSession and redirect.
- */
- private def signin(account: Account, hash: Option[String]) = {
+ * Set account information into HttpSession and redirect.
+ */
+ private def signin(account: Account, redirectUrl: String = "/") = {
session.setAttribute(Keys.Session.LoginAccount, account)
updateLastLoginDate(account.userName)
@@ -95,14 +158,10 @@
redirect("/" + account.userName + "/_edit")
}
- flash.get(Keys.Flash.Redirect).asInstanceOf[Option[String]].map { redirectUrl =>
- if(redirectUrl.stripSuffix("/") == request.getContextPath){
- redirect("/")
- } else {
- redirect(redirectUrl + hash.getOrElse(""))
- }
- }.getOrElse {
+ if (redirectUrl.stripSuffix("/") == request.getContextPath) {
redirect("/")
+ } else {
+ redirect(redirectUrl)
}
}
diff --git a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala
index 65cdd2d..adc00e3 100644
--- a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala
+++ b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala
@@ -2,23 +2,23 @@
import java.io.FileInputStream
-import gitbucket.core.admin.html
-import gitbucket.core.service.{AccountService, RepositoryService, SystemSettingsService}
-import gitbucket.core.util.{AdminAuthenticator, Mailer}
-import gitbucket.core.ssh.SshServer
-import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
-import SystemSettingsService._
-import gitbucket.core.util.Implicits._
-import gitbucket.core.util.SyntaxSugars._
-import gitbucket.core.util.Directory._
-import gitbucket.core.util.StringUtil._
-import org.scalatra.forms._
-import org.apache.commons.io.IOUtils
-import org.scalatra.i18n.Messages
import com.github.zafarkhaja.semver.{Version => Semver}
import gitbucket.core.GitBucketCoreModule
-import org.scalatra._
+import gitbucket.core.admin.html
+import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
+import gitbucket.core.service.SystemSettingsService._
+import gitbucket.core.service.{AccountService, RepositoryService}
+import gitbucket.core.ssh.SshServer
+import gitbucket.core.util.Directory._
+import gitbucket.core.util.Implicits._
+import gitbucket.core.util.StringUtil._
+import gitbucket.core.util.SyntaxSugars._
+import gitbucket.core.util.{AdminAuthenticator, Mailer}
+import org.apache.commons.io.IOUtils
import org.json4s.jackson.Serialization
+import org.scalatra._
+import org.scalatra.forms._
+import org.scalatra.i18n.Messages
import scala.collection.JavaConverters._
import scala.collection.mutable.ListBuffer
@@ -70,6 +70,13 @@
"ssl" -> trim(label("Enable SSL", optional(boolean()))),
"keystore" -> trim(label("Keystore", optional(text())))
)(Ldap.apply)),
+ "oidcAuthentication" -> trim(label("OIDC", boolean())),
+ "oidc" -> optionalIfNotChecked("oidcAuthentication", mapping(
+ "issuer" -> trim(label("Issuer", text(required))),
+ "clientID" -> trim(label("Client ID", text(required))),
+ "clientSecret" -> trim(label("Client secret", text(required))),
+ "jwsAlgorithm" -> trim(label("Signature algorithm", optional(text())))
+ )(OIDC.apply)),
"skinName" -> trim(label("AdminLTE skin name", text(required)))
)(SystemSettings.apply).verifying { settings =>
Vector(
diff --git a/src/main/scala/gitbucket/core/model/AccountFederation.scala b/src/main/scala/gitbucket/core/model/AccountFederation.scala
new file mode 100644
index 0000000..35a6b4b
--- /dev/null
+++ b/src/main/scala/gitbucket/core/model/AccountFederation.scala
@@ -0,0 +1,19 @@
+package gitbucket.core.model
+
+trait AccountFederationComponent { self: Profile =>
+ import profile.api._
+
+ lazy val AccountFederations = TableQuery[AccountFederations]
+
+ class AccountFederations(tag: Tag) extends Table[AccountFederation](tag, "ACCOUNT_FEDERATION") {
+ val issuer = column[String]("ISSUER")
+ val subject = column[String]("SUBJECT")
+ val userName = column[String]("USER_NAME")
+ def * = (issuer, subject, userName) <> (AccountFederation.tupled, AccountFederation.unapply)
+
+ def byPrimaryKey(issuer: String, subject: String): Rep[Boolean] =
+ (this.issuer === issuer.bind) && (this.subject === subject.bind)
+ }
+}
+
+case class AccountFederation(issuer: String, subject: String, userName: String)
diff --git a/src/main/scala/gitbucket/core/model/Profile.scala b/src/main/scala/gitbucket/core/model/Profile.scala
index a70af57..7cace80 100644
--- a/src/main/scala/gitbucket/core/model/Profile.scala
+++ b/src/main/scala/gitbucket/core/model/Profile.scala
@@ -1,7 +1,7 @@
package gitbucket.core.model
-import gitbucket.core.util.DatabaseConfig
import com.github.takezoe.slick.blocking.BlockingJdbcProfile
+import gitbucket.core.util.DatabaseConfig
trait Profile {
val profile: BlockingJdbcProfile
@@ -61,6 +61,7 @@
with RepositoryWebHookEventComponent
with AccountWebHookComponent
with AccountWebHookEventComponent
+ with AccountFederationComponent
with ProtectedBranchComponent
with DeployKeyComponent
with ReleaseComponent
diff --git a/src/main/scala/gitbucket/core/service/AccountFederationService.scala b/src/main/scala/gitbucket/core/service/AccountFederationService.scala
new file mode 100644
index 0000000..932f115
--- /dev/null
+++ b/src/main/scala/gitbucket/core/service/AccountFederationService.scala
@@ -0,0 +1,74 @@
+package gitbucket.core.service
+
+import gitbucket.core.model.Profile.profile.blockingApi._
+import gitbucket.core.model.Profile.{AccountFederations, Accounts}
+import gitbucket.core.model.{Account, AccountFederation}
+import gitbucket.core.util.SyntaxSugars.~
+import org.slf4j.LoggerFactory
+
+trait AccountFederationService extends AccountService {
+
+ private val logger = LoggerFactory.getLogger(classOf[AccountFederationService])
+
+ /**
+ * Get or create a user account federated with OIDC or SAML IdP.
+ *
+ * @param issuer Issuer
+ * @param subject Subject
+ * @param mailAddress Mail address
+ * @param preferredUserName Username (if this is none, username will be generated from the mail address)
+ * @param fullName Fullname (defaults to username)
+ * @return Account
+ */
+ def getOrCreateFederatedUser(issuer: String,
+ subject: String,
+ mailAddress: String,
+ preferredUserName: Option[String],
+ fullName: Option[String])(implicit s: Session): Option[Account] =
+ getAccountByFederation(issuer, subject) match {
+ case Some(account) if !account.isRemoved =>
+ Some(account)
+ case Some(account) =>
+ logger.info(s"Federated user found but disabled: userName=${account.userName}, isRemoved=${account.isRemoved}")
+ None
+ case None =>
+ findAvailableUserName(preferredUserName, mailAddress) flatMap { userName =>
+ createAccount(userName, "", fullName.getOrElse(userName), mailAddress, isAdmin = false, None, None)
+ createAccountFederation(issuer, subject, userName)
+ getAccountByUserName(userName)
+ }
+ }
+
+ private def extractSafeStringForUserName(s: String) = """^[a-zA-Z0-9][a-zA-Z0-9\-_.]*""".r.findPrefixOf(s)
+
+ /**
+ * Find an available username from the preferred username or mail address.
+ *
+ * @param mailAddress Mail address
+ * @param preferredUserName Username
+ * @return Available username
+ */
+ private def findAvailableUserName(preferredUserName: Option[String], mailAddress: String)(implicit s: Session): Option[String] = {
+ preferredUserName.flatMap(n => extractSafeStringForUserName(n)).orElse(extractSafeStringForUserName(mailAddress)) match {
+ case Some(safeUserName) =>
+ getAccountByUserName(safeUserName, includeRemoved = true) match {
+ case None => Some(safeUserName)
+ case Some(_) =>
+ logger.info(s"User ($safeUserName) already exists. preferredUserName=$preferredUserName, mailAddress=$mailAddress")
+ None
+ }
+ case None =>
+ logger.info(s"Could not extract username from preferredUserName=$preferredUserName, mailAddress=$mailAddress")
+ None
+ }
+ }
+
+ def getAccountByFederation(issuer: String, subject: String)(implicit s: Session): Option[Account] =
+ AccountFederations.filter(_.byPrimaryKey(issuer, subject))
+ .join(Accounts).on { case af ~ ac => af.userName === ac.userName }
+ .map { case _ ~ ac => ac }
+ .firstOption
+
+ def createAccountFederation(issuer: String, subject: String, userName: String)(implicit s: Session): Unit =
+ AccountFederations insert AccountFederation(issuer, subject, userName)
+}
diff --git a/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala b/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala
new file mode 100644
index 0000000..73adfff
--- /dev/null
+++ b/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala
@@ -0,0 +1,177 @@
+package gitbucket.core.service
+
+import java.net.URI
+
+import com.nimbusds.jose.JOSEException
+import com.nimbusds.jose.proc.BadJOSEException
+import com.nimbusds.jose.util.DefaultResourceRetriever
+import com.nimbusds.oauth2.sdk._
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic
+import com.nimbusds.oauth2.sdk.id.{ClientID, Issuer, State}
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
+import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator
+import com.nimbusds.openid.connect.sdk.{AuthenticationErrorResponse, _}
+import gitbucket.core.model.Account
+import gitbucket.core.model.Profile.profile.blockingApi._
+import org.slf4j.LoggerFactory
+
+import scala.collection.JavaConverters.mapAsJavaMap
+
+/**
+ * Service class for the OpenID Connect authentication.
+ */
+trait OpenIDConnectService extends AccountFederationService {
+
+ private val logger = LoggerFactory.getLogger(classOf[OpenIDConnectService])
+
+ private val JWK_REQUEST_TIMEOUT = 5000
+
+ private val OIDC_SCOPE = new Scope(
+ OIDCScopeValue.OPENID,
+ OIDCScopeValue.EMAIL,
+ OIDCScopeValue.PROFILE)
+
+ /**
+ * Obtain the OIDC metadata from discovery and create an authentication request.
+ *
+ * @param issuer Issuer, used to construct the discovery endpoint URL, e.g. https://accounts.google.com
+ * @param clientID Client ID (given by the issuer)
+ * @param redirectURI Redirect URI
+ * @return Authentication request
+ */
+ def createOIDCAuthenticationRequest(issuer: Issuer,
+ clientID: ClientID,
+ redirectURI: URI): AuthenticationRequest = {
+ val metadata = OIDCProviderMetadata.resolve(issuer)
+ new AuthenticationRequest(
+ metadata.getAuthorizationEndpointURI,
+ new ResponseType(ResponseType.Value.CODE),
+ OIDC_SCOPE,
+ clientID,
+ redirectURI,
+ new State(),
+ new Nonce())
+ }
+
+ /**
+ * Proceed the OpenID Connect authentication.
+ *
+ * @param params Query parameters of the authentication response
+ * @param redirectURI Redirect URI
+ * @param state State saved in the session
+ * @param nonce Nonce saved in the session
+ * @param oidc OIDC settings
+ * @return ID token
+ */
+ def authenticate(params: Map[String, String],
+ redirectURI: URI,
+ state: State,
+ nonce: Nonce,
+ oidc: SystemSettingsService.OIDC)(implicit s: Session): Option[Account] =
+ validateOIDCAuthenticationResponse(params, state, redirectURI) flatMap { authenticationResponse =>
+ obtainOIDCToken(authenticationResponse.getAuthorizationCode, nonce, redirectURI, oidc) flatMap { claims =>
+ Seq("email", "preferred_username", "name").map(k => Option(claims.getStringClaim(k))) match {
+ case Seq(Some(email), preferredUsername, name) =>
+ getOrCreateFederatedUser(claims.getIssuer.getValue, claims.getSubject.getValue, email, preferredUsername, name)
+ case _ =>
+ logger.info(s"OIDC ID token must have an email claim: claims=${claims.toJSONObject}")
+ None
+ }
+ }
+ }
+
+ /**
+ * Validate the authentication response.
+ *
+ * @param params Query parameters of the authentication response
+ * @param state State saved in the session
+ * @param redirectURI Redirect URI
+ * @return Authentication response
+ */
+ def validateOIDCAuthenticationResponse(params: Map[String, String], state: State, redirectURI: URI): Option[AuthenticationSuccessResponse] =
+ try {
+ AuthenticationResponseParser.parse(redirectURI, mapAsJavaMap(params)) match {
+ case response: AuthenticationSuccessResponse =>
+ if (response.getState == state) {
+ Some(response)
+ } else {
+ logger.info(s"OIDC authentication state did not match: response(${response.getState}) != session($state)")
+ None
+ }
+ case response: AuthenticationErrorResponse =>
+ logger.info(s"OIDC authentication response has error: ${response.getErrorObject}")
+ None
+ }
+ } catch {
+ case e: ParseException =>
+ logger.info(s"OIDC authentication response has error: $e")
+ None
+ }
+
+ /**
+ * Obtain the ID token from the OpenID Provider.
+ *
+ * @param authorizationCode Authorization code in the query string
+ * @param nonce Nonce
+ * @param redirectURI Redirect URI
+ * @param oidc OIDC settings
+ * @return Token response
+ */
+ def obtainOIDCToken(authorizationCode: AuthorizationCode,
+ nonce: Nonce,
+ redirectURI: URI,
+ oidc: SystemSettingsService.OIDC): Option[IDTokenClaimsSet] = {
+ val metadata = OIDCProviderMetadata.resolve(oidc.issuer)
+ val tokenRequest = new TokenRequest(metadata.getTokenEndpointURI,
+ new ClientSecretBasic(oidc.clientID, oidc.clientSecret),
+ new AuthorizationCodeGrant(authorizationCode, redirectURI),
+ OIDC_SCOPE)
+ val httpResponse = tokenRequest.toHTTPRequest.send()
+ try {
+ OIDCTokenResponseParser.parse(httpResponse) match {
+ case response: OIDCTokenResponse =>
+ validateOIDCTokenResponse(response, metadata, nonce, oidc)
+ case response: TokenErrorResponse =>
+ logger.info(s"OIDC token response has error: ${response.getErrorObject.toJSONObject}")
+ None
+ }
+ } catch {
+ case e: ParseException =>
+ logger.info(s"OIDC token response has error: $e")
+ None
+ }
+ }
+
+ /**
+ * Validate the token response.
+ *
+ * @param response Token response
+ * @param metadata OpenID Provider metadata
+ * @param nonce Nonce
+ * @return Claims
+ */
+ def validateOIDCTokenResponse(response: OIDCTokenResponse,
+ metadata: OIDCProviderMetadata,
+ nonce: Nonce,
+ oidc: SystemSettingsService.OIDC): Option[IDTokenClaimsSet] =
+ Option(response.getOIDCTokens.getIDToken) match {
+ case Some(jwt) =>
+ val validator = oidc.jwsAlgorithm map { jwsAlgorithm =>
+ new IDTokenValidator(metadata.getIssuer, oidc.clientID, jwsAlgorithm, metadata.getJWKSetURI.toURL,
+ new DefaultResourceRetriever(JWK_REQUEST_TIMEOUT, JWK_REQUEST_TIMEOUT))
+ } getOrElse {
+ new IDTokenValidator(metadata.getIssuer, oidc.clientID)
+ }
+ try {
+ Some(validator.validate(jwt, nonce))
+ } catch {
+ case e@(_: BadJOSEException | _: JOSEException) =>
+ logger.info(s"OIDC ID token has error: $e")
+ None
+ }
+ case None =>
+ logger.info(s"OIDC token response does not have a valid ID token: ${response.toJSONObject}")
+ None
+ }
+}
diff --git a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala
index bb50df4..2ad682b 100644
--- a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala
+++ b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala
@@ -1,11 +1,14 @@
package gitbucket.core.service
-import gitbucket.core.util.Implicits._
+import javax.servlet.http.HttpServletRequest
+
+import com.nimbusds.jose.JWSAlgorithm
+import com.nimbusds.oauth2.sdk.auth.Secret
+import com.nimbusds.oauth2.sdk.id.{ClientID, Issuer}
+import gitbucket.core.service.SystemSettingsService._
import gitbucket.core.util.ConfigUtil._
import gitbucket.core.util.Directory._
import gitbucket.core.util.SyntaxSugars._
-import SystemSettingsService._
-import javax.servlet.http.HttpServletRequest
trait SystemSettingsService {
@@ -54,6 +57,15 @@
ldap.keystore.foreach(x => props.setProperty(LdapKeystore, x))
}
}
+ props.setProperty(OidcAuthentication, settings.oidcAuthentication.toString)
+ if (settings.oidcAuthentication) {
+ settings.oidc.map { oidc =>
+ props.setProperty(OidcIssuer, oidc.issuer.getValue)
+ props.setProperty(OidcClientId, oidc.clientID.getValue)
+ props.setProperty(OidcClientSecret, oidc.clientSecret.getValue)
+ oidc.jwsAlgorithm.map { x => props.setProperty(OidcJwsAlgorithm, x.getName) }
+ }
+ }
props.setProperty(SkinName, settings.skinName.toString)
using(new java.io.FileOutputStream(GitBucketConf)){ out =>
props.store(out, null)
@@ -113,6 +125,17 @@
} else {
None
},
+ getValue(props, OidcAuthentication, false),
+ if (getValue(props, OidcAuthentication, false)) {
+ Some(OIDC(
+ getValue(props, OidcIssuer, ""),
+ getValue(props, OidcClientId, ""),
+ getValue(props, OidcClientSecret, ""),
+ getOptionValue(props, OidcJwsAlgorithm, None)
+ ))
+ } else {
+ None
+ },
getValue(props, SkinName, "skin-blue")
)
}
@@ -139,6 +162,8 @@
smtp: Option[Smtp],
ldapAuthentication: Boolean,
ldap: Option[Ldap],
+ oidcAuthentication: Boolean,
+ oidc: Option[OIDC],
skinName: String){
def baseUrl(request: HttpServletRequest): String = baseUrl.fold {
@@ -166,6 +191,16 @@
ssl: Option[Boolean],
keystore: Option[String])
+ case class OIDC(
+ issuer: Issuer,
+ clientID: ClientID,
+ clientSecret: Secret,
+ jwsAlgorithm: Option[JWSAlgorithm])
+ object OIDC {
+ def apply(issuer: String, clientID: String, clientSecret: String, jwsAlgorithm: Option[String]): OIDC =
+ new OIDC(new Issuer(issuer), new ClientID(clientID), new Secret(clientSecret), jwsAlgorithm.map(JWSAlgorithm.parse))
+ }
+
case class Smtp(
host: String,
port: Option[Int],
@@ -221,6 +256,11 @@
private val LdapTls = "ldap.tls"
private val LdapSsl = "ldap.ssl"
private val LdapKeystore = "ldap.keystore"
+ private val OidcAuthentication = "oidc_authentication"
+ private val OidcIssuer = "oidc.issuer"
+ private val OidcClientId = "oidc.client_id"
+ private val OidcClientSecret = "oidc.client_secret"
+ private val OidcJwsAlgorithm = "oidc.jws_algorithm"
private val SkinName = "skinName"
private def getValue[A: ClassTag](props: java.util.Properties, key: String, default: A): A = {
diff --git a/src/main/scala/gitbucket/core/util/Keys.scala b/src/main/scala/gitbucket/core/util/Keys.scala
index 3581879..ee83e52 100644
--- a/src/main/scala/gitbucket/core/util/Keys.scala
+++ b/src/main/scala/gitbucket/core/util/Keys.scala
@@ -26,6 +26,21 @@
val DashboardPulls = "dashboard/pulls"
/**
+ * Session key for the OpenID Connect authentication.
+ */
+ val OidcState = "oidc/state"
+
+ /**
+ * Session key for the OpenID Connect authentication.
+ */
+ val OidcNonce = "oidc/nonce"
+
+ /**
+ * Session key for the redirect back to after SSO.
+ */
+ val OidcRedirectBackURI = "oidc/redirectBackURI"
+
+ /**
* Generate session key for the issue search condition.
*/
def Issues(owner: String, name: String) = s"${owner}/${name}/issues"
diff --git a/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala b/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala
new file mode 100644
index 0000000..7fd0cc1
--- /dev/null
+++ b/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala
@@ -0,0 +1,15 @@
+package gitbucket.core.util
+
+import com.nimbusds.jose.JWSAlgorithm
+import com.nimbusds.jose.JWSAlgorithm.Family
+
+import scala.collection.JavaConverters.asScalaSet
+
+object OpenIDConnectUtil {
+ val JWS_ALGORITHMS: Map[String, Set[JWSAlgorithm]] = Seq(
+ "HMAC" -> Family.HMAC_SHA,
+ "RSA" -> Family.RSA,
+ "ECDSA" -> Family.EC,
+ "EdDSA" -> Family.ED
+ ).toMap.map { case (name, family) => (name, asScalaSet(family).toSet) }
+}
diff --git a/src/main/twirl/gitbucket/core/admin/system.scala.html b/src/main/twirl/gitbucket/core/admin/system.scala.html
index b9017b7..d03660f 100644
--- a/src/main/twirl/gitbucket/core/admin/system.scala.html
+++ b/src/main/twirl/gitbucket/core/admin/system.scala.html
@@ -1,6 +1,5 @@
@(info: Option[Any])(implicit context: gitbucket.core.controller.Context)
-@import gitbucket.core.util.DatabaseConfig
-@import gitbucket.core.view.helpers
+@import gitbucket.core.util.{DatabaseConfig, OpenIDConnectUtil}
@gitbucket.core.html.main("System settings"){
@gitbucket.core.admin.html.menu("system"){
@gitbucket.core.helper.html.information(info)
@@ -287,6 +286,56 @@
+
+
@@ -456,5 +505,9 @@
$('#ldapAuthentication').change(function(){
$('.ldap input').prop('disabled', !$(this).prop('checked'));
}).change();
+
+ $('#oidcAuthentication').change(function(){
+ $('.oidc input, .oidc select').prop('disabled', !$(this).prop('checked'));
+ }).change();
});
diff --git a/src/main/twirl/gitbucket/core/signinform.scala.html b/src/main/twirl/gitbucket/core/signinform.scala.html
index 09c27d2..686e30d 100644
--- a/src/main/twirl/gitbucket/core/signinform.scala.html
+++ b/src/main/twirl/gitbucket/core/signinform.scala.html
@@ -4,6 +4,15 @@
Sign in
+ @if(context.settings.oidcAuthentication){
+ -
+
+
+ }
-