diff --git a/.gitignore b/.gitignore index eedcf4e4..51f80ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .vscode/ .gitattributes -*.code-workspace \ No newline at end of file +*.code-workspace + +# using TestClass.cls for objecscript functionality exploration / convince myself that things work the way I expect them to +cls/SourceControl/Git/TestClass.cls \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ecca164..1cd85682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Partial support for production decomposition with the new interoperability editors - Added Lock Branch setting to prevent switching branches for a protected namespace (#709) - Tooltips on branch operations in Git UI (#725) +- Support for https connections (#279) ### Fixed - Changing system mode (environment name) in settings persists after instance restart (#655) diff --git a/README.md b/README.md index f8eb9ba8..715b8abd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Embedded Git support for InterSystems platforms, supporting unified source contr ``` do ##class(SourceControl.Git.API).Configure() ``` - This will also allow you to generate an SSH key for use as (e.g.) a deploy key and to initialize or clone a git repo. + This will also allow you to generate an SSH key for use as (e.g.) a deploy key and to initialize or clone a git repo. (If you want to use https instead, please see the documentation [here])(/docs/https.md) 3. If using VSCode: Set up `isfs` server-side editing. First, save your current workspace in which you have the code open. Then, open the `.code-workspace` file generated by VS Code and add the following to the list of folders: ``` { @@ -151,6 +151,9 @@ Assuming you have the local and remote repositories created, `git config core.sshCommand 'ssh -i ~/.ssh/'` 8. Test the refresh button for the remote branches on the WebUI, fetch from the source control menu in Studio or VS Code, and `git fetch` in Git Bash. All 3 should work without any issues. +### HTTPS Support +We recommend that people connect to their remote git repository using SSH. If you cannot use SSH connections, we also have support for HTTPS connection through OAuth2. See [our documentation for setting up an https connection](/docs/https.md). + ## Editing files in the Git repository server-side There are some circumstances where you'll want to edit files in the Git repository on the IRIS server. For example, diff --git a/cls/SourceControl/Git/API.cls b/cls/SourceControl/Git/API.cls index 7b86a881..f5ecfc71 100644 --- a/cls/SourceControl/Git/API.cls +++ b/cls/SourceControl/Git/API.cls @@ -39,18 +39,18 @@ ClassMethod Configure() /// API for git pull - just wraps Utils /// - pTerminateOnError: if set to 1, this will terminate on error if there are any errors in the pull, otherwise will return status -ClassMethod Pull(pTerminateOnError As %Boolean = 0) +ClassMethod Pull(pTerminateOnError As %Boolean = 0) As %Status { - set st = ##class(SourceControl.Git.Utils).Pull(,pTerminateOnError) - if pTerminateOnError && $$$ISERR(st) { + set sc = ##class(SourceControl.Git.Utils).Pull(,pTerminateOnError) + if pTerminateOnError && $$$ISERR(sc) { Do $System.Process.Terminate($Job,1) } - quit st + quit sc } /// Imports all items from the Git repository into IRIS. /// - pForce: if true, will import an item even if the last updated timestamp in IRIS is later than that of the file on disk. -ClassMethod ImportAll(pForce As %Boolean = 0) as %Status +ClassMethod ImportAll(pForce As %Boolean = 0) As %Status { return ##class(SourceControl.Git.Utils).ImportAll(pForce) } diff --git a/cls/SourceControl/Git/Build.cls b/cls/SourceControl/Git/Build.cls index 02d93165..c017de30 100644 --- a/cls/SourceControl/Git/Build.cls +++ b/cls/SourceControl/Git/Build.cls @@ -14,4 +14,4 @@ ClassMethod BuildUIForDevMode(devMode As %Boolean, rootDirectory As %String) write !, $zf(-100, "/SHELL", "npm", "run", "build", "--prefix", webUIDirectory) } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls index 565deac2..4a772f04 100644 --- a/cls/SourceControl/Git/Extension.cls +++ b/cls/SourceControl/Git/Extension.cls @@ -11,6 +11,7 @@ XData Menu + @@ -193,6 +194,7 @@ Method OnSourceMenuItem(name As %String, ByRef Enabled As %String, ByRef Display set Enabled = $CASE(name, "Status": 1, "GitWebUI" : 1, + "Authenticate":1, "Import": 1, "ImportForce": 1, "NewBranch": BranchLocked, @@ -206,6 +208,7 @@ Method OnSourceMenuItem(name As %String, ByRef Enabled As %String, ByRef Display // cases "Status": 1, "GitWebUI" : 1, + "Authenticate" : 1, "Export": 1, "ExportForce": 1, "Import": 1, diff --git a/cls/SourceControl/Git/Log.cls b/cls/SourceControl/Git/Log.cls index a6d8a98d..c6ac8854 100644 --- a/cls/SourceControl/Git/Log.cls +++ b/cls/SourceControl/Git/Log.cls @@ -144,4 +144,3 @@ Storage Default } } - diff --git a/cls/SourceControl/Git/OAuth2.cls b/cls/SourceControl/Git/OAuth2.cls new file mode 100644 index 00000000..07a1cd14 --- /dev/null +++ b/cls/SourceControl/Git/OAuth2.cls @@ -0,0 +1,151 @@ +Include %syPrompt + +IncludeGenerator %syPrompt + +Class SourceControl.Git.OAuth2 Extends %RegisteredObject +{ + +/// GenerateVerifier returns a cryptographically random string compliant with RFC 7636 (PKCE) +/// Requirements: +/// - Minimum 43 characters, maximum 128 characters +/// - Character set: [A-Za-z0-9-._~] (unreserved URI characters only) +/// - High entropy (256+ bits) +/// +/// Implementation: Generates 64 random characters (384 bits entropy) from the allowed charset +ClassMethod GenerateVerifier() As %String +{ + // RFC 7636 unreserved characters: [A-Za-z0-9-._~] + set charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + set charsetLen = $LENGTH(charset) + + // Generate 64 character verifier (384 bits entropy, well above 256-bit minimum) + set verifier = "" + new $NAMESPACE + set $NAMESPACE = "%SYS" + + for i=1:1:64 { + // Get random byte (0-255) + set randomByte = $ASCII(##class(%SYSTEM.Encryption).GenCryptRand(1)) + // Map to charset index (1-66) + set index = (randomByte # charsetLen) + 1 + set verifier = verifier _ $EXTRACT(charset, index) + } + + return verifier +} + +/// Builds the authorization code URL for the given configuration +ClassMethod AuthCodeURL(c As SourceControl.Git.OAuth2.Config, namespace As %String, Output state, Output verifier) As %String +{ + set state = ..GenerateVerifier() + set verifier = ..GenerateVerifier() + set url = c.AuthCodeURL(state, verifier) + return url +} + +ClassMethod GetURLsFromRemote(remote As %String, Output authCodeURL, Output tokenURL) As %Boolean +{ + if remote [ "github.com/" { + set authCodeURL = "https://github.com/login/oauth/authorize" + set tokenURL = "https://github.com/login/oauth/access_token" + return 1 + } elseif remote [ "gitlab" { + set gitlaburl = $Piece(remote, ".com", 1) _ ".com/" + set authCodeURL = gitlaburl _ "/oauth/authorize" + set tokenURL = gitlaburl _ "/oauth/token" + return 1 + } else { + return 0 + } +} + +ClassMethod FixRemoteURL(url As %String) As %String +{ + /// OAuth Https authentication requires connecting as api + if ($extract(url,1,5) = "https") { + if (url [ "@") { + return url + } else { + set url = "https://api@"_$piece(url,"https://",2) + } + } + return url +} + +ClassMethod GetToken() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetAccessToken($username, .err, .code) +} + +/// Base64URLEncode converts data to base64url format (RFC 4648 Section 5) +/// Base64url is like standard base64 but URL-safe: +/// - Replace + with - +/// - Replace / with _ +/// - Remove padding (=) +/// Used for PKCE code_challenge encoding per RFC 7636 +ClassMethod Base64URLEncode(data As %String) As %String +{ + // Convert to standard base64 + set base64 = $SYSTEM.Encryption.Base64Encode(data) + + // Convert to base64url: replace +/= with -_ + set base64url = $REPLACE(base64, "+", "-") + set base64url = $REPLACE(base64url, "/", "_") + + // Remove padding + set base64url = $REPLACE(base64url, "=", "") + + return base64url +} + +ClassMethod DeleteOAuthConfig(username As %String) As %Status +{ + set sc = $$$OK + set error = "" + set code = "" + + // Delete access token + // Check if token exists first + set token = ##class(SourceControl.Git.Util.CredentialManager).GetAccessToken(username, .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to get access token with error: %1; code: %2'",error,code)) + } + if token '= "" { + do ##class(SourceControl.Git.Util.CredentialManager).DeleteAccessToken(username, .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to delete access token with error: %1; code: %2'",error,code)) + } + } + + // Clear refresh token + do ##class(SourceControl.Git.Util.CredentialManager).SetRefreshToken(username, "", .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to clear refresh token with error: %1; code: %2'",error,code)) + } + + // Clear client ID; secret + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(username, "clientid", "", .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to clear clientid with error: %1; code: %2'",error,code)) + } + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(username, "clientsecret", "", .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to clear clientsecret with error: %1; code: %2'",error,code)) + } + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(username, "state", "", .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to clear state with error: %1; code: %2'",error,code)) + } + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(username, "verifier", "", .error, .code) + if error '= "" { + return $$$ERROR($$$GeneralError,$$$FormatText("Failed to clear verifier with error: %1; code: %2'",error,code)) + } + + // Delete persistent config object + if ##class(SourceControl.Git.OAuth2.Config).%ExistsId(username) { + set sc = ##class(SourceControl.Git.OAuth2.Config).%DeleteId(username) + } + return sc +} + +} diff --git a/cls/SourceControl/Git/OAuth2/Config.cls b/cls/SourceControl/Git/OAuth2/Config.cls new file mode 100644 index 00000000..80dab644 --- /dev/null +++ b/cls/SourceControl/Git/OAuth2/Config.cls @@ -0,0 +1,521 @@ +Class SourceControl.Git.OAuth2.Config Extends %Persistent +{ + +/// Name is the identifier for this configuration +Property Name As %String(MAXLEN = 127); + +/// ClientID is the OAuth Application ID. Stored in private memory store only accessible by user +Property ClientID As %String(MAXLEN = "") [ Transient ]; + +/// ClientSecret is the OAuth Application secret. Stored in private memory store only accessible by user +Property ClientSecret As %String(MAXLEN = "") [ Transient ]; + +/// Endpoint contains the resource server's token endpoint +Property Endpoint As Endpoint; + +/// RedirectURL is the URL to redirect the auth token +/// to after authenticating with the resource owner +Property RedirectURL As %String(MAXLEN = ""); + +/// State is the CSRF protection token for OAuth2 flow (ephemeral, not persisted) +Property State As %String [ Transient ]; + +/// Verifier is the PKCE code verifier (ephemeral, not persisted) +Property Verifier As %String [ Transient ]; + +/// Scopes specifies the list of scopes we are requesting access to +Property Scopes As %List; + +Property Username As %String; + +/// DirectToken indicates if using a direct authentication token instead of OAuth2 flow +Property DirectToken As %String [ InitialExpression = 0 ]; + +/// AccessTokenExpiry stores the timestamp when the access token expires +Property AccessTokenExpiry As %TimeStamp; + +/// ConnectionTimeout in seconds for establishing TCP connection (default: 10 seconds) +Property ConnectionTimeout As %Integer [ InitialExpression = 10 ]; + +/// ResponseTimeout in seconds for receiving full HTTP response (default: 30 seconds) +Property ResponseTimeout As %Integer [ InitialExpression = 30 ]; + +Index Username On Username [ IdKey, Unique ]; + +Method ClientIDSet(InputValue As %String) As %Status +{ + set code = "", error = "" + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(..Username, "clientid", InputValue, .error, .code) + if (code '= 1) || (error '= "") { + return $$$ERROR($$$GeneralError,"Set failed with following error: "_error) + } + return $$$OK +} + +Method ClientIDGet() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair(..Username,"clientid", .error, .code) +} + +Method ClientSecretSet(InputValue As %String) As %Status +{ + set code = "", error = "" + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair(..Username, "clientsecret", InputValue, .error, .code) + if (code '= 1) || (error '= "") { + return $$$ERROR($$$GeneralError,"Set failed with following error: "_error) + } + return $$$OK +} + +Method ClientSecretGet() As %String +{ + return ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair(..Username, "clientsecret", .error, .code) +} + +ClassMethod GetConfig(username As %String) As SourceControl.Git.OAuth2.Config +{ + set config = ##class(SourceControl.Git.OAuth2.Config).%OpenId(username) + + return config +} + +/// TODO: We will need a authStyleCache when we use autodetect for Endpoint.AuthStyle in the future +Method %OnNew(configName As %String, clientID As %String, clientSecret As %String, authEndpoint As %String, tokenEndpoint As %String, redirectURL As %String, scopes As %List = "") As %Status +{ + set ..Name = configName + set ..Username = $username + set ..ClientID = clientID + set ..ClientSecret = clientSecret + set ..Endpoint = ##class(Endpoint).%New() + set ..Endpoint.AuthURL = authEndpoint + set ..Endpoint.TokenURL = tokenEndpoint + set ..RedirectURL = redirectURL + set ..DirectToken = 0 + + if ('scopes) { + set scopes = $lb("repo") + } + set ..Scopes = scopes + + return $$$OK +} + +Method AuthCodeURL(state As %String, verifier As %String) As %String +{ + set params("response_type") = "code" + set params("client_id") = ..ClientID + set:(..RedirectURL '= "") params("redirect_uri") = ..RedirectURL + set:(state '= "") params("state") = state + set:($LISTLENGTH(..Scopes) > 0) params("scope") = $LISTTOSTRING(..Scopes," ") + if verifier '= "" { + // Generate SHA-256 hash and encode in base64url per RFC 7636 + // SHAHash returns raw bytes, which we need to encode in base64url + set hashBytes = ##class(%SYSTEM.Encryption).SHAHash(256, verifier) + set codeChallenge = ##class(SourceControl.Git.OAuth2).Base64URLEncode(hashBytes) + + set params("code_challenge_method") = "S256" + set params("code_challenge") = codeChallenge + } + + return ..GetURLWithParams(..Endpoint.AuthURL, .params) +} + +Method Exchange(authCode As %String, verifier As %String, Output sc As %Status) As %String +{ + set sc = $$$OK + + // Create and configure token request + set request = ..CreateTokenRequest(.sc) + if sc '= $$$OK { + return "" + } + + // Set authorization code grant parameters + do request.SetParam("grant_type", "authorization_code") + do request.SetParam("code", authCode) + do request.SetParam("code_verifier", verifier) + + if ..ClientID '= "" { + do request.SetParam("client_id", ..ClientID) + } + + if ..ClientSecret '= "" { + do request.SetParam("client_secret", ..ClientSecret) + } + + // Execute request and get response + set obj = ..ExecuteTokenRequest(request, .sc) + if sc '= $$$OK { + return "" + } + + // Extract access token + if obj.%IsDefined("access_token") && (obj.%GetTypeOf("access_token") = "string") { + set accessToken = obj.%Get("access_token") + + // Calculate and store token expiration + if obj.%IsDefined("expires_in") { + set expiresIn = obj.%Get("expires_in") + set ..AccessTokenExpiry = ..CalculateTokenExpiry(expiresIn) + } else { + set ..AccessTokenExpiry = "" + } + + // Parse and store refresh_token if present + if obj.%IsDefined("refresh_token") { + set refreshToken = obj.%Get("refresh_token") + + // Store refresh token in CredentialManager + do ##class(SourceControl.Git.Util.CredentialManager).SetRefreshToken( + ..Username, + refreshToken, + .refreshError, + .refreshCode + ) + + if (refreshCode '= 1) || (refreshError '= "") { + // Log but don't fail - refresh token is optional + // TODO: Add logging when available + } + } + + // Save config to persist expiry timestamp + set saveSc = ..%Save() + if saveSc '= $$$OK { + // Log save error but don't fail token exchange + // TODO: Add logging when available + } + + return accessToken + } else { + set sc = $$$ERROR($$$GeneralError,"Unable to read access_token from response") + return "" + } +} + +/// CreateTokenRequest creates and configures an HTTP request for the token endpoint +/// Returns configured HttpRequest object +Method CreateTokenRequest(Output sc As %Status) As %Net.HttpRequest +{ + set sc = $$$OK + + // Parse token endpoint URL + do ##class(%Net.URLParser).Decompose(..Endpoint.TokenURL, .urlComponents) + + // Create HTTP request + set request = ##class(%Net.HttpRequest).%New() + set request.Server = urlComponents("host") + + if urlComponents("scheme") = "https" { + set request.Https = 1 + } else { + set request.Https = 0 + } + + // Apply timeout configuration + set request.Timeout = ..ResponseTimeout + set request.OpenTimeout = ..ConnectionTimeout + + do request.SetHeader("Accept", "application/json") + + // Configure SSL + do ..CreateSSLConfigIfNonExistent("GitExtensionForIris") + set request.SSLConfiguration = "GitExtensionForIris" + + return request +} + +/// ExecuteTokenRequest posts to token endpoint and validates the response +/// Returns parsed JSON object or "" on error +Method ExecuteTokenRequest(request As %Net.HttpRequest, Output sc As %Status) As %DynamicObject +{ + set sc = $$$OK + + // Parse URL to get path + do ##class(%Net.URLParser).Decompose(..Endpoint.TokenURL, .urlComponents) + + // POST to token endpoint + set postSc = request.Post(urlComponents("path")) + if postSc '= $$$OK { + set sc = $$$ERROR($$$GeneralError, "Network error: "_$SYSTEM.Status.GetErrorText(postSc)) + return "" + } + + // Check HTTP status + set httpStatus = request.HttpResponse.StatusCode + if httpStatus '= 200 { + set errorMsg = "HTTP "_httpStatus + + try { + set errorObj = {}.%FromJSON(request.HttpResponse.Data) + if errorObj.%IsDefined("error") { + set errorMsg = errorMsg_": "_errorObj.%Get("error") + if errorObj.%IsDefined("error_description") { + set errorMsg = errorMsg_" - "_errorObj.%Get("error_description") + } + } else { + set errorMsg = errorMsg_": "_request.HttpResponse.ReasonPhrase + } + } catch { + set errorMsg = errorMsg_": "_request.HttpResponse.ReasonPhrase + } + + // Add specific guidance based on status code + if httpStatus = 400 { + set errorMsg = errorMsg_" (Invalid parameters)" + } elseif httpStatus = 401 { + set errorMsg = errorMsg_" (Invalid credentials)" + } elseif httpStatus = 403 { + set errorMsg = errorMsg_" (Not authorized)" + } elseif httpStatus = 404 { + set errorMsg = errorMsg_" (Token endpoint not found)" + } elseif httpStatus = 429 { + set errorMsg = errorMsg_" (Rate limited)" + } elseif (httpStatus >= 500) && (httpStatus < 600) { + set errorMsg = errorMsg_" (Server error)" + } + + set sc = $$$ERROR($$$GeneralError, errorMsg) + return "" + } + + // Parse response JSON + try { + set contentType = request.HttpResponse.GetHeader("CONTENT-TYPE") + + // Try JSON first (GitHub should return this with Accept: application/json) + set obj = {}.%FromJSON(request.HttpResponse.Data) + return obj + } catch ex { + // If JSON parsing fails, try URL-encoded format + + // Reset stream position + do request.HttpResponse.Data.Rewind() + set responseText = request.HttpResponse.Data.Read() + + // Try parsing as URL-encoded (access_token=xxx&token_type=bearer) + if responseText [ "access_token=" { + set obj = {} + // Parse URL-encoded response + for i=1:1:$LENGTH(responseText,"&") { + set pair = $PIECE(responseText,"&",i) + if pair '= "" { + set key = $PIECE(pair,"=",1) + set value = $PIECE(pair,"=",2,*) // Handle values with = in them + do obj.%Set(key, value) + } + } + return obj + } + + set sc = ex.AsStatus() + return "" + } +} + +/// CalculateTokenExpiry calculates the expiry timestamp from expires_in seconds +/// Returns ODBC timestamp string (YYYY-MM-DD HH:MM:SS) +ClassMethod CalculateTokenExpiry(expiresInSeconds As %Integer) As %TimeStamp +{ + set currentHorolog = $HOROLOG + set currentDate = $PIECE(currentHorolog, ",", 1) + set currentSeconds = $PIECE(currentHorolog, ",", 2) + + // Add expires_in seconds and handle day rollover + set expirySeconds = currentSeconds + expiresInSeconds + set daysToAdd = expirySeconds \ 86400 // Integer division (seconds per day) + set expirySeconds = expirySeconds # 86400 // Modulo (remaining seconds) + set expiryDate = currentDate + daysToAdd + + // Reconstruct $HOROLOG format and convert to ODBC timestamp + set expiryHorolog = expiryDate _ "," _ expirySeconds + return $ZDATETIME(expiryHorolog, 3) +} + +/// RefreshAccessToken uses the refresh token to obtain a new access token +/// Returns new access token or "" on failure +Method RefreshAccessToken(Output sc As %Status) As %String +{ + set sc = $$$OK + + // Retrieve refresh token from CredentialManager + set refreshToken = ##class(SourceControl.Git.Util.CredentialManager).GetRefreshToken( + ..Username, + .error, + .code + ) + + // Check if we have a refresh token + if (refreshToken = "") || (error '= "") { + set sc = $$$ERROR($$$GeneralError, "No refresh token available: "_error) + return "" + } + + // Create and configure token request + set request = ..CreateTokenRequest(.sc) + if sc '= $$$OK { + return "" + } + + // Set refresh token grant parameters (RFC 6749 Section 6) + do request.SetParam("grant_type", "refresh_token") + do request.SetParam("refresh_token", refreshToken) + + if ..ClientID '= "" { + do request.SetParam("client_id", ..ClientID) + } + + if ..ClientSecret '= "" { + do request.SetParam("client_secret", ..ClientSecret) + } + + // Execute request and get response + set obj = ..ExecuteTokenRequest(request, .sc) + if sc '= $$$OK { + // If refresh token is invalid/expired (400/401), clear it + set errorText = $SYSTEM.Status.GetErrorText(sc) + if (errorText [ "HTTP 400") || (errorText [ "HTTP 401") { + do ##class(SourceControl.Git.Util.CredentialManager).SetRefreshToken( + ..Username, + "", + .clearError, + .clearCode + ) + } + return "" + } + + // Extract new access token + if obj.%IsDefined("access_token") { + set accessToken = obj.%Get("access_token") + + // Calculate and store token expiration + if obj.%IsDefined("expires_in") { + set expiresIn = obj.%Get("expires_in") + set ..AccessTokenExpiry = ..CalculateTokenExpiry(expiresIn) + } + + // Update refresh token if new one provided (token rotation) + if obj.%IsDefined("refresh_token") { + set newRefreshToken = obj.%Get("refresh_token") + do ##class(SourceControl.Git.Util.CredentialManager).SetRefreshToken( + ..Username, + newRefreshToken, + .refreshError, + .refreshCode + ) + } + + // Store new access token + do ##class(SourceControl.Git.Util.CredentialManager).SetAccessToken( + ..Username, + accessToken, + .tokenError, + .tokenCode + ) + + if (tokenCode '= 1) || (tokenError '= "") { + set sc = $$$ERROR($$$GeneralError, "Failed to store refreshed access token: "_tokenError) + return "" + } + + // Save updated config (expiry timestamp) + set saveSc = ..%Save() + if saveSc '= $$$OK { + // Log but don't fail + // TODO: Add logging + } + + return accessToken + } else { + set sc = $$$ERROR($$$GeneralError, "Token refresh response missing access_token") + return "" + } +} + +/// IsTokenExpired checks if the access token has expired +/// Returns 1 (true) if expired or no expiry set, 0 (false) if still valid +Method IsTokenExpired() As %Boolean +{ + // If no expiry timestamp set, consider expired + if ..AccessTokenExpiry = "" { + return 1 + } + + // Compare current time with expiry timestamp + // Both are in ODBC format (YYYY-MM-DD HH:MM:SS) which is string-comparable + set currentTime = $ZDATETIME($HOROLOG, 3) + return currentTime >= ..AccessTokenExpiry +} + +ClassMethod CreateSSLConfigIfNonExistent(name As %String) +{ + do ##class(%zpkg.isc.sc.git.SSLConfig).CreateSSLConfigIfNonExistent(name) +} + +ClassMethod GetURLWithParams(url As %String, ByRef params As %String) As %String +{ + if $find(url, "?") { + set url = url_"&" + } else { + set url = url_"?" + } + + set curParamKey = "" + for { + set isFirstIter = (curParamKey = "") + set curParamKey = $order(params(curParamKey), 1, curParamValue) + + set isLastIter = (curParamKey = "") + set:'(isFirstIter || isLastIter) url = url_"&" + + quit:(isLastIter) + + set url = url_$$$URLENCODE(curParamKey)_"="_$$$URLENCODE(curParamValue) + } + return url +} + +Storage Default +{ + + +%%CLASSNAME + + +Name + + +Endpoint + + +RedirectURL + + +Scopes + + +DirectToken + + +AccessTokenExpiry + + +ConnectionTimeout + + +ResponseTimeout + + +Namespace + + +^SourceControl.Git.O7826.ConfigD +ConfigDefaultData +^SourceControl.Git.O7826.ConfigD +^SourceControl.Git.O7826.ConfigI +^SourceControl.Git.O7826.ConfigS +%Storage.Persistent +} + +} diff --git a/cls/SourceControl/Git/OAuth2/Endpoint.cls b/cls/SourceControl/Git/OAuth2/Endpoint.cls new file mode 100644 index 00000000..c4bf8af6 --- /dev/null +++ b/cls/SourceControl/Git/OAuth2/Endpoint.cls @@ -0,0 +1,28 @@ +Class SourceControl.Git.OAuth2.Endpoint Extends %SerialObject +{ + +Property AuthURL As %String; + +Property DeviceAuthURL As %String; + +Property TokenURL As %String; + +Storage Default +{ + + +AuthURL + + +DeviceAuthURL + + +TokenURL + + +EndpointState +^SourceControl.Git7826.EndpointS +%Storage.Serial +} + +} diff --git a/cls/SourceControl/Git/PullEventHandler/Default.cls b/cls/SourceControl/Git/PullEventHandler/Default.cls index 9a72a8ad..71044970 100644 --- a/cls/SourceControl/Git/PullEventHandler/Default.cls +++ b/cls/SourceControl/Git/PullEventHandler/Default.cls @@ -15,4 +15,4 @@ Method OnPull() As %Status quit ##class(SourceControl.Git.PullEventHandler.IncrementalLoad)$this.OnPull() } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/Settings.cls b/cls/SourceControl/Git/Settings.cls index cd2b7d9a..2651478a 100644 --- a/cls/SourceControl/Git/Settings.cls +++ b/cls/SourceControl/Git/Settings.cls @@ -48,6 +48,12 @@ Property gitUserName As %String(MAXLEN = 255) [ InitialExpression = {##class(Sou /// Attribution: Email address for user ${username} Property gitUserEmail As %String(MAXLEN = 255) [ InitialExpression = {##class(SourceControl.Git.Utils).GitUserEmail()} ]; +/// URL for git remote +Property gitRemoteURL As %String(MAXLEN = "") [ InitialExpression = {##class(SourceControl.Git.Utils).GetConfiguredRemote()} ]; + +/// Type of git remote (SSH or HTTPS (Only with OAuth)) +Property gitRemoteType As %String(VALUELIST = ",HTTPS,SSH") [ InitialExpression = {##class(SourceControl.Git.Settings).GetRemoteType(##class(SourceControl.Git.Utils).GetConfiguredRemote())} ]; + /// Whether mapped items should be read-only, preventing them from being added to source control Property mappedItemsReadOnly As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).MappedItemsReadOnly()} ]; @@ -81,6 +87,9 @@ Property environmentName As %String(MAXLEN = "") [ InitialExpression = {##class( /// Whether the branch should or should not be locked down from changing namespaces Property lockBranch As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).LockBranch()} ]; +/// Whether we are using OAuth for https +Property OAuth As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).UsingOAuth()} ]; + /// (Optional) A namespace-specific string that may be included in mapping configurations as to support multi-namespace repositories Property mappingsToken As %String(MAXLEN = "") [ InitialExpression = {##class(SourceControl.Git.Utils).MappingsToken()} ]; @@ -188,9 +197,15 @@ Method %Save() As %Status set @storage@("settings", "warnInstanceWideUncommitted") = ..warnInstanceWideUncommitted set @storage@("settings", "basicMode") = ..systemBasicMode set @storage@("settings", "environmentName") = ..environmentName + set @storage@("settings","gitRemoteType") = ..gitRemoteType + set @storage@("settings","gitRemoteURL") = ..gitRemoteURL set @storage@("settings", "lockBranch") = ..lockBranch + + set @storage@("settings","OAuth") = ..OAuth + set @storage@("settings", "mappingsToken") = ..mappingsToken set @storage@("settings", "sshConfigFile") = ..sshConfigFile + if ..basicMode = "system" { kill @storage@("settings", "user", $username, "basicMode") } else { @@ -339,6 +354,13 @@ ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator, Internal ] do %code.WriteLine(" set list(4) = ""FAILOVER""") do %code.WriteLine(" set list(5) = """"") do %code.WriteLine(" set response = ##class(%Library.Prompt).GetArray("_promptQuoted_",.value,.list,,,,"_defaultPromptFlag_")") + } elseif ((propertyDef) && (propertyDef.Name ="gitRemoteType")) { + do %code.WriteLine(" if (inst.gitRemoteURL '= """") { set value = inst.GetRemoteType(inst.gitRemoteURL)}") + } elseif ((propertyDef) && (propertyDef.Name = "OAuth")) { + do %code.WriteLine(" if (inst.gitRemoteType = ""HTTPS"") {") + do %code.WriteLine(" set value = 0") + do %code.WriteLine(" set response = ##class(%Library.Prompt).GetYesNo("_promptQuoted_",.value,,"_defaultPromptFlag) + do %code.WriteLine(" }") } else { do %code.WriteLine(" set response = ##class(%Library.Prompt).GetString("_promptQuoted_",.value,,,,"_defaultPromptFlag_")") } @@ -475,14 +497,53 @@ Method OnAfterConfigure() As %Boolean [ Internal ] $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init")) $$$ThrowOnError(workMgr.WaitForComplete()) } elseif (value = 2) { - set response = ##class(%Library.Prompt).GetString("Git remote URL (note: if authentication is required, use SSH, not HTTPS):",.remote,,,,defaultPromptFlag) + set remote = $select(..gitRemoteURL:..gitRemoteURL, 1:"") + set response = ##class(%Library.Prompt).GetString("Git remote URL:",.remote,,,,defaultPromptFlag) if (response '= $$$SuccessResponse) { quit } if (remote = "") { quit } - // using work queue manager ensures proper OS user context/file ownership + set ..gitRemoteURL = remote + set ..gitRemoteType = ..GetRemoteType(..gitRemoteURL) + do ..%Save() + + + if ((..gitRemoteType = "HTTPS") && ('..OAuth)) { + set value = 0 + set response = ##class(%Library.Prompt).GetYesNo("Do you want to use OAuth for your https remote",.value,,defaultPromptFlag) + if (response '= $$$SuccessResponse) { + quit + } + set ..OAuth = value + do ..%Save() + } + + + if ((..gitRemoteType = "HTTPS") && ..OAuth) { + set remote = ##class(SourceControl.Git.OAuth2).FixRemoteURL(remote) + Write !, "Please navigate to the Embedded Git UI on your browser, and press ""Authenticate"" in the bottom left corner." + Write !, "Once that process is complete, return here to verify cloning was successful" + Write !, "*Note: You must log in to the Management Portal as the current user" + + // poll attempt count + set try = 0 + // poll every `SLEEPTIME` seconds + set SLEEPTIME = 5 + // stop polling after `TIMEOUT` seconds + set TIMEOUT = 300 + While try*SLEEPTIME < TIMEOUT { + do ##class(SourceControl.Git.Util.CredentialManager).GetAccessToken($username, .err, .code) + + if err = "" { + // token was saved successfully, exit loop + quit + } + } + } + + // using work queue manager ensures proper OS user context/file ownership set workMgr = $System.WorkMgr.%New("") $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Clone",remote)) $$$ThrowOnError(workMgr.WaitForComplete()) @@ -560,4 +621,9 @@ Method SaveDefaults() As %Boolean return ##class(%zpkg.isc.sc.git.Defaults).SetDefaultSettings(defaults) } +ClassMethod GetRemoteType(remoteURL As %String) As %String +{ + return $select(remoteURL [ "https": "HTTPS",1:"SSH") +} + } diff --git a/cls/SourceControl/Git/Util/Buffer.cls b/cls/SourceControl/Git/Util/Buffer.cls index d1601f9b..8134e516 100644 --- a/cls/SourceControl/Git/Util/Buffer.cls +++ b/cls/SourceControl/Git/Util/Buffer.cls @@ -280,4 +280,4 @@ Method UsePreviousDeviceAndSettings() [ Internal, Private ] } } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/Util/CredentialManager.cls b/cls/SourceControl/Git/Util/CredentialManager.cls new file mode 100644 index 00000000..76fe26b9 --- /dev/null +++ b/cls/SourceControl/Git/Util/CredentialManager.cls @@ -0,0 +1,242 @@ +Class SourceControl.Git.Util.CredentialManager Extends %RegisteredObject +{ + +/// Description +Property pvtStore [ Internal, Private ]; + +/// Creates the `..GetEventName()` named event +/// Waits on signals and services request +Method Run() [ Private ] +{ + do ##class(%SYSTEM.Event).Create(..GetEventName()) + + set i%pvtStore = ##class(PrivateMemoryStore).%New() + set code = 0 + while (code '= -1) { + try { + set code = ..Wait(.msgType, .msgContent) + if (code = 1) { + do ..HandleMessage(msgType, msgContent) + } + } catch err { + do err.Log() + } + } +} + +/// GetAccessToken is used to retrieve the access token for a particular git user +/// gitUsername (optional) default: `""` -- username for which token is to be retrieved +/// error -- pass by reference -- returns error: if error '= "" we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +/// Returns fetched access token +ClassMethod GetAccessToken(gitUsername As %String = "", Output error As %String, Output code As %String) As %String +{ + set token = "" + set $lb(token, error) = ..Signal("GET", $lb($JOB, gitUsername_"-accesstoken"), .code) + return token +} + +/// SetAccessToken is used to set the access token for a particular git user +/// gitUsername (optional) default: `""` -- username for which token is to be set +/// error -- pass by reference -- returns error: if (error '= "") we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +ClassMethod SetAccessToken(gitUsername As %String = "", gitToken As %String, Output error As %String, Output code) +{ + set error = "" + set $lb(, error) = ..Signal("SET", $lb($JOB, gitUsername_"-accesstoken", gitToken), .code) +} + +ClassMethod DeleteAccessToken(gitUsername As %String, Output error As %String, Output code) +{ + set $lb(, error) = ..Signal("DEL", $lb($JOB, gitUsername_"-accesstoken"), .code) +} + +/// GetRefreshToken retrieves the refresh token for a git user +/// gitUsername (optional) default: `""` -- username for which refresh token is to be retrieved +/// error -- pass by reference -- returns error: if error '= "" we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +/// Returns fetched refresh token +ClassMethod GetRefreshToken(gitUsername As %String = "", Output error As %String, Output code As %String) As %String +{ + set token = "" + set $lb(token, error) = ..Signal("GET", $lb($JOB, gitUsername_"-refreshtoken"), .code) + return token +} + +/// SetRefreshToken sets the refresh token for a git user +/// gitUsername (optional) default: `""` -- username for which refresh token is to be set +/// error -- pass by reference -- returns error: if (error '= "") we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +ClassMethod SetRefreshToken(gitUsername As %String = "", gitToken As %String, Output error As %String, Output code) +{ + set $lb(, error) = ..Signal("SET", $lb($JOB, gitUsername_"-refreshtoken", gitToken), .code) +} + +ClassMethod SendResponse(toPID As %Integer, message As %String, error As %String) [ Private ] +{ + if $System.Event.Signal(toPID, $lb(message, error)) '= 1 { + #; do ..LogForDaemon("Unable to send message: """_message_""" to: "_toPID) + } +} + +/// SetKeyPair is used to set the value for a key-value pair for a particular git user +/// gitUsername default: `""` -- username for which value is to be set +/// error -- pass by reference -- returns error: if (error '= "") we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +ClassMethod SetKeyPair(gitUsername As %String = "", key As %String, value As %String, Output error As %String, Output code) +{ + if (gitUsername = "") { + do ..SendResponse($JOB, "", "provide username") + quit + } + set $lb(,error) = ..Signal("SET",$lb($JOB,gitUsername_"-"_key,value), .code) +} + +/// GetKeyPair is used to retreive the value for a key for a particular git user +/// gitUsername default: `""` -- username for which the value is to be retreived +/// error -- pass by reference -- returns error: if error '= "" we got an error from the daemon process +/// code -- pass by reference -- returns a code (refer to $SYSTEM.Event.Wait() for descriptions of possible values) +/// Returns fetched value +ClassMethod GetKeyPair(gitUsername As %String = "", key As %String, Output error As %String, Output code) As %String +{ + set $lb(value, error) = ..Signal("GET", $lb($JOB, gitUsername_"-"_key), .code) + return value +} + +Method HandleMessage(msgType As %String, msgContent As %String) [ Private ] +{ + try { + // make sure the message is appropriately formatted + set $lb(senderPID, gitUsername, gitToken) = msgContent + } catch err { + do err.Log() + quit + } + + if '$data(senderPID) { + #; do ..LogForDaemon("No source PID provided") + quit + } + + if '$data(gitUsername) { + do ..SendResponse(senderPID, "", "provide username") + quit + } + + set irisUsername = ##class(%SYS.ProcessQuery).%OpenId(senderPID).UserName + // key that the token would be mapped from + set key = $lb(irisUsername, gitUsername) + if msgType = "GET" { + if i%pvtStore.KeyExists(key) { + do ..SendResponse(senderPID, i%pvtStore.Retrieve(key), "") + } else { + do ..SendResponse(senderPID, "", "") + } + } elseif msgType = "SET" { + if '$data(gitToken) { + do ..SendResponse(senderPID, "", "provide git token") + quit + } + do i%pvtStore.Store(key, gitToken) + do ..SendResponse(senderPID, gitToken, "") + } elseif msgType = "DEL" { + do i%pvtStore.Clear(key) + do ..SendResponse(senderPID, "", "") + } +} + +ClassMethod Signal(msgType As %String, msgContent As %String, Output responseCode) As %String [ Private ] +{ + // Make sure the daemon is running + do ..Start() + + // Clear any pending messages for this process' resource + do $System.Event.Clear($Job) + + // Signal the daemon + do ##class(%SYSTEM.Event).Signal(..GetEventName(),$ListBuild(msgType,msgContent)) + set $listbuild(responseCode,msg) = $System.Event.WaitMsg("",5) + return msg +} + +Method Wait(Output msgType As %String, Output msgContent As %String) As %Integer +{ + set (msg,msgType,msgContent) = "" + set $listbuild(code,msg) = ##class(%SYSTEM.Event).WaitMsg(..GetEventName(),1) + if $listvalid(msg) { + set $listbuild(msgType,msgContent) = msg + } + return code +} + +ClassMethod GetEventName() As %String [ Private ] +{ + return $Name(^isc.git.sc("Daemon")) //^"_$classname() +} + +ClassMethod Start() [ Private ] +{ + if ..CheckStatus() { + quit + } + job ..StartInternal():(:::1):5 + if ('$test) { + $$$ThrowStatus($$$ERROR($$$GeneralError,"Daemon process failed to start")) + } + while '$System.Event.Defined(..GetEventName()) { + hang 1 + if $increment(wait) > 5 { + // this is a no-no situation, right? + // we would never want to return from Start without starting + quit + } + } +} + +ClassMethod StartInternal() +{ + try { + set lock = $System.AutoLock.Lock(..GetEventName(), , 2) + set daemon = ..%New() + do daemon.Run() + } catch err { + #; do LogForDaemon(err.DisplayString()) + } +} + +ClassMethod StopMemoryStore() +{ + do ..Stop() +} + +ClassMethod Stop() [ Private ] +{ + do ##class(%SYSTEM.Event).Delete(..GetEventName()) + set pid = ^$LOCK(..GetEventName(), "OWNER") + if (pid > 0) { + do $System.Process.Terminate(pid) + } +} + +ClassMethod Restart() [ Private ] +{ + do ..Stop() + do ..Start() +} + +ClassMethod CheckStatus() As %Boolean [ Private ] +{ + return ($data(^$LOCK(..GetEventName())) = 10) +} + +/// This callback method is invoked by the %Close method to +/// provide notification that the current object is being closed. +/// +///

The return value of this method is ignored. +Method %OnClose() As %Status [ Private, ServerOnly = 1 ] +{ + do ##class(%SYSTEM.Event).Delete(..GetEventName()) + return $$$OK +} + +} diff --git a/cls/SourceControl/Git/Util/PrivateMemoryStore.cls b/cls/SourceControl/Git/Util/PrivateMemoryStore.cls new file mode 100644 index 00000000..02c72568 --- /dev/null +++ b/cls/SourceControl/Git/Util/PrivateMemoryStore.cls @@ -0,0 +1,191 @@ +/// Key-value store where values are stored in private memory (for high security). +Class SourceControl.Git.Util.PrivateMemoryStore Extends %RegisteredObject +{ + +Property buffer [ Internal, Private ]; + +Property map [ Internal, MultiDimensional, Private ]; + +Property offset [ InitialExpression = 0, Internal, Private ]; + +Property size [ Internal, Private ]; + +Parameter defaultSize = 128; + +Method %OnNew(size As %Integer) As %Status [ Private, ServerOnly = 1 ] +{ + if $DATA(size) && $ISVALIDNUM(size) && (size >= 0) { + set i%size = size + } else { + set i%size = ..#defaultSize + } + + set i%buffer = $zu(106,1,i%size) + return $$$OK +} + +/// Store `key`: `value` to the map +/// throws an exception if key = "" +Method Store(key As %String, value As %String) +{ + if key = "" { + throw ##class(%Exception.General).%New("INVALID_KEY_EXCEPTION","999",, "Invalid key for private memory store `""""`") + } + set length = $length(value) + // this will clear it if it exists + do ..Clear(key) + set requiredSize = length + i%offset + if (requiredSize > i%size) { + // TODO: there is definitely a better way to find the appropriate next size + // using log_2() but won't do that right now + + if i%size=0 { + set newSize = i%defaultSize + } else { + set newSize = i%size*2 + } + + while requiredSize > newSize { + set newSize = newSize*2 + } + set newBuffer = $zu(106,1,newSize) + + // move values from buffer to newBuffer + do ..compactBuffer(newBuffer, .newMap, .newOffset) + + // clear current buffer and deallocate + do ..deallocateBuffer() + + // set to new values + set i%buffer = newBuffer + set i%size = newSize + set i%offset = newOffset + merge i%map = newMap + } + // add mapping for the key + set i%map(key) = $lb(i%offset,length) + set i%offset = ..insertIntoMemoryStore(value, i%buffer, i%offset) +} + +/// Retreives the value associated with `key` +/// Returns `""` if key does not exist +Method Retrieve(key As %String) As %RawString +{ + return:('..KeyExists(key)) "" + + set $listbuild(offset,length) = i%map(key) + return $view(i%buffer+offset,-3,-length) +} + +/// Deletes the key and its associated value from the map +/// Returns silently if key does not exist +Method Clear(key As %String) +{ + quit:('..KeyExists(key)) + + kill i%map(key) + + do ..compactBuffer(i%buffer, .newMap, .newOffset) + // update the map and offset + kill i%map + merge i%map = newMap + set i%offset = newOffset +} + +Method %OnClose() As %Status [ Private, ServerOnly = 1 ] +{ + do ..deallocateBuffer() +} + +/// Returns true if `key` exists in the map +/// Returns false otherwise +Method KeyExists(key As %String) As %Boolean +{ + if key = "" { + return 0 + } + return $Get(i%map(key)) '= "" +} + +// PRIVATE METHODS ====> + +/// Writes to Buffer and returns new offset +Method insertIntoMemoryStore(value, buffer, offset As %Integer) As %Integer [ Private ] +{ + set length = $length(value) + // mode of -3 means the process's address space + view buffer+offset:-3:-length:value + return offset + length +} + +Method clearBuffer() [ Private ] +{ + // nulls out buffer + FOR i = 1:1:i%size { + view i%buffer+i:-3:-1:0 + } +} + +/// iterate through ..map and move data from ..buffer to buffer +Method compactBuffer(buffer, Output newMap, Output newOffset) [ Private ] +{ + // pointer to the next place to insert into the buffer + set newOffset = 0 + kill newMap + + do ..getInverseMap(.inverseMap) + // iterate through the offsets in ascending order + set curOffset = "" + for { + set curOffset = $order(inverseMap(curOffset)) + quit:(curOffset = "") + + set key = inverseMap(curOffset) + set value = ..Retrieve(key) + set newMap(key) = $lb(newOffset, $length(value)) + set newOffset = ..insertIntoMemoryStore(value, buffer, newOffset) + } +} + +Method deallocateBuffer() [ Private ] +{ + do ..clearBuffer() + set i%size = 0 + kill i%map + do $zu(106,0,i%buffer) +} + +// using this method to iterate by sorted offset + +// inverseMap is of array type + +Method getInverseMap(Output inverseMap) [ Private ] +{ + kill inverseMap + set iterKey = "" + for { + set iterKey = $order(i%map(iterKey)) + quit:(iterKey = "") + + set list = i%map(iterKey) + set $listbuild(offset,) = list + + set inverseMap(offset) = iterKey + } +} + +// util method to print ..map + +Method printMap() +{ + set iterKey = "" + w ! + for { + set iterKey = $order(i%map(iterKey)) + quit:(iterKey = "") + + w iterKey, ": ", $LISTTOSTRING(i%map(iterKey)), ! + } +} + +} diff --git a/cls/SourceControl/Git/Util/ProductionConflictResolver.cls b/cls/SourceControl/Git/Util/ProductionConflictResolver.cls index 3b3c4d1c..ba446832 100644 --- a/cls/SourceControl/Git/Util/ProductionConflictResolver.cls +++ b/cls/SourceControl/Git/Util/ProductionConflictResolver.cls @@ -8,4 +8,3 @@ Parameter ExpectedConflictTag = ""; Parameter OutputIndent = " "; } - diff --git a/cls/SourceControl/Git/Util/ResolutionManager.cls b/cls/SourceControl/Git/Util/ResolutionManager.cls index a36c310c..b4e96308 100644 --- a/cls/SourceControl/Git/Util/ResolutionManager.cls +++ b/cls/SourceControl/Git/Util/ResolutionManager.cls @@ -118,4 +118,3 @@ Method ResolveClass(className As %String, fileName As %String, resolverClass As } } - diff --git a/cls/SourceControl/Git/Util/RuleConflictResolver.cls b/cls/SourceControl/Git/Util/RuleConflictResolver.cls index 1e05fbc7..beb61256 100644 --- a/cls/SourceControl/Git/Util/RuleConflictResolver.cls +++ b/cls/SourceControl/Git/Util/RuleConflictResolver.cls @@ -4,4 +4,3 @@ Class SourceControl.Git.Util.RuleConflictResolver Extends SourceControl.Git.Util Parameter ExpectedConflictTag = ""; } - diff --git a/cls/SourceControl/Git/Util/Singleton.cls b/cls/SourceControl/Git/Util/Singleton.cls index 6b9d0132..4f7432a1 100644 --- a/cls/SourceControl/Git/Util/Singleton.cls +++ b/cls/SourceControl/Git/Util/Singleton.cls @@ -166,4 +166,4 @@ Method %RemoveOref() As %Status [ CodeMode = objectgenerator, Final, Internal, P Quit $$$OK } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/Util/XMLConflictResolver.cls b/cls/SourceControl/Git/Util/XMLConflictResolver.cls index 5294221e..9b34a404 100644 --- a/cls/SourceControl/Git/Util/XMLConflictResolver.cls +++ b/cls/SourceControl/Git/Util/XMLConflictResolver.cls @@ -61,4 +61,3 @@ Method ResolveStream(stream As %Stream.Object) } } - diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls index f2faa53a..abe88761 100644 --- a/cls/SourceControl/Git/Utils.cls +++ b/cls/SourceControl/Git/Utils.cls @@ -82,6 +82,21 @@ ClassMethod DecomposeProdAllowIDE() As %Boolean [ CodeMode = expression ] $Get(@..#Storage@("settings","decomposeProdAllowIDE"), 1) } +ClassMethod UsingOAuth() As %Boolean [ CodeMode = expression ] +{ +$Get(@..#Storage@("settings","OAuth"), 0) +} + +ClassMethod GitRemoteType() As %String +{ + return ##class(SourceControl.Git.Settings).GetRemoteType(..GitRemoteURL()) +} + +ClassMethod GitRemoteURL() As %String [ CodeMode = expression ] +{ +$Get(@..#Storage@("settings","gitRemoteURL"), 0) +} + ClassMethod FavoriteNamespaces() As %String { set favNamespaces = [] @@ -282,6 +297,9 @@ ClassMethod UserAction(InternalName As %String, MenuName As %String, ByRef Targe } elseif (menuItemName = "GitWebUI") { set Action = 2 + externalBrowser set Target = urlPrefix _ "/isc/studio/usertemplates/gitsourcecontrol/webuidriver.csp/"_$namespace_"/"_$zconvert(InternalName,"O","URL")_"?"_urlPostfix + } elseif (menuItemName = "Authenticate") { + set Action = 2 + externalBrowser + set Target = urlPrefix _ "/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp?"_urlPostfix } elseif (menuItemName = "Export") || (menuItemName = "ExportForce") { write !, "==export start==",! set ec = ..ExportAll($case(menuItemName="ExportForce",1:$$$Force,:0)) @@ -692,7 +710,11 @@ ClassMethod Clone(remote As %String) As %Status { set settings = ##class(SourceControl.Git.Settings).%New() // TODO: eventually use /ENV flag with GIT_TERMINAL_PROMPT=0. (This isn't doc'd yet and is only in really new versions.) - set sc = ..RunGitWithArgs(.errStream, .outStream, "clone", remote, settings.namespaceTemp) + set rc = ..RunGitWithArgs(.errStream, .outStream, "clone", remote, settings.namespaceTemp) + if rc '= 0 { + set errMsg = errStream.Read() + quit $$$ERROR($$$GeneralError, "Git clone failed: "_errMsg) + } // can I substitute this with the new print method? $$$NewLineIfNonEmptyStream(errStream) while 'errStream.AtEnd { @@ -1893,7 +1915,13 @@ ClassMethod RunGitWithArgs(Output errStream, Output outStream, args...) As %Inte ClassMethod RunGitCommand(command As %String, Output errStream, Output outStream, args...) As %Integer { - quit ..RunGitCommandWithInput(command,,.errStream,.outStream,args...) + try { + set rc = ..RunGitCommandWithInput(command, "", .errStream, .outStream, args...) + } catch ex { + set errStream = ex.DisplayString() + do ex.Log() + } + quit rc } ClassMethod RunGitCommandWithInput(command As %String, inFile As %String = "", Output errStream, Output outStream, args...) As %Integer @@ -1929,12 +1957,18 @@ ClassMethod RunGitCommandWithInput(command As %String, inFile As %String = "", O set email = ..GitUserEmail() } - set newArgs($increment(newArgs)) = "-c" - set newArgs($increment(newArgs)) = "user.name="_username - set newArgs($increment(newArgs)) = "-c" - set newArgs($increment(newArgs)) = "user.email="_email - set newArgs($increment(newArgs)) = "-c" - set newArgs($increment(newArgs)) = "credential.interactive=false" + if username '= "" { + set newArgs($increment(newArgs)) = "-c" + set newArgs($increment(newArgs)) = "user.name="_username + } + if email '= "" { + set newArgs($increment(newArgs)) = "-c" + set newArgs($increment(newArgs)) = "user.email="_email + } + if ('..UsingOAuth()) { + set newArgs($increment(newArgs)) = "-c" + set newArgs($increment(newArgs)) = "credential.interactive=false" + } } set newArgs($increment(newArgs)) = command @@ -2099,6 +2133,33 @@ ClassMethod RunGitCommandWithInput(command As %String, inFile As %String = "", O set gitCommand = $extract(..GitBinPath(),2,*-1) set baseArgs = "/STDOUT="_$$$QUOTE(outLog)_" /STDERR="_$$$QUOTE(errLog)_$case(inFile, "":"", :" /STDIN="_$$$QUOTE(inFile)) + // Use OAuth Authentication if needed (TODO: Should this be done always, or only for certain commands?) + if (..UsingOAuth()) { + set token = ##class(SourceControl.Git.OAuth2).GetToken() + if (token '= "") { + if ($$$isWINDOWS) { + set askpassFile = "C:\Windows\Temp\askpass.bat" + if ('##class(%File).Exists(askpassFile)) { + open askpassFile:"NW":0 + use askpassFile Write "@echo off",!,"echo %GIT_TOKEN%",! + close askpassFile + } + } else { + set askpassFile = "/tmp/askpass.sh" + if ('##class(%File).Exists(askpassFile)) { + open askpassFile:"NW":0 + use askpassFile Write "#!/bin/bash",!,"echo $GIT_TOKEN",! + close askpassFile + // Make file executable + Do $zf(-1, "chmod +x "_askpassFile) + } + } + set env("GIT_TOKEN") = token + set env("GIT_ASKPASS") = askpassFile + set env("GIT_TERMINAL_PROMPT") = 0 + } + } + try { // Inject instance manager directory as global git config home directory // On Linux, this avoids trying to use /root/.config/git/attributes for global git config @@ -2106,10 +2167,12 @@ ClassMethod RunGitCommandWithInput(command As %String, inFile As %String = "", O set returnCode = $zf(-100,"/ENV=env... "_baseArgs,gitCommand,newArgs...) } catch e { if $$$isWINDOWS { - set returnCode = $zf(-100,baseArgs,gitCommand,newArgs...) + k env("XDG_CONFIG_HOME") + set returnCode = $zf(-100,"/ENV=env... "_baseArgs,gitCommand,newArgs...) } else { + k env("XDG_CONFIG_HOME") // If can't inject XDG_CONFIG_HOME (older IRIS version), need /SHELL on Linux to avoid permissions errors trying to use root's config - set returnCode = $zf(-100,"/SHELL "_baseArgs,gitCommand,newArgs...) + set returnCode = $zf(-100,"/SHELL /ENV=env... "_baseArgs,gitCommand,newArgs...) } } @@ -3158,6 +3221,7 @@ ClassMethod BaselineExport(pCommitMessage = "", pPushToRemote = "") As %Status /// Returns the url for the "origin" remote repository ClassMethod GetConfiguredRemote(Output remoteExists As %Boolean = 0, Output sc As %Status = {$$$OK}) As %String { + set url = "" set exitCode = ..RunGitCommand("remote",.err,.out,"get-url","origin") if (exitCode = 0) { set remoteExists = 1 @@ -3185,14 +3249,17 @@ ClassMethod SetConfiguredRemote(url) As %String { do ..GetConfiguredRemote(.remoteExists) set returnCode = $select( - remoteExists&&(url=""): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"remove","origin"), - remoteExists&&(url'=""): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"set-url","origin",url), - 'remoteExists&&(url'=""): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"add","origin",url), - 1: 0) + (remoteExists&&(url="")): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"remove","origin"), + (remoteExists&&(url'="")): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"set-url","origin",url), + (('remoteExists)&&(url'="")): ##class(SourceControl.Git.Utils).RunGitCommandWithInput("remote",,.errStream,.outStream,"add","origin",url), + 1: -1) + if (returnCode = -1) { + quit "" + } if (returnCode '= 0) { $$$ThrowStatus($$$ERROR($$$GeneralError,"git reported failure")) } - set output = outStream.ReadLine(outStream.Size) + set output = outStream.Read() quit output } @@ -3335,6 +3402,31 @@ ClassMethod GitUnstage(Output output As %Library.DynamicObject) As %Status return $$$OK } +ClassMethod WriteLineToFile(filePath As %String, line As %String) +{ + Set file=##class(%File).%New(filePath) + Do file.Open("WSN") + Do file.WriteLine(line) +} + +ClassMethod Authenticated() As %Boolean +{ + if (##class(SourceControl.Git.Utils).UsingOAuth()) { + try { + // Run a git command that requires access to the remote + // if does not work, then we are unauthenticated + set returncode = ##class(SourceControl.Git.Utils).RunGitCommandWithInput("ls-remote","",.errStream,.outStream,) + do errStream.Rewind() + if (errStream.ReadLine()) '= "" { + return 0 + } + } catch e { + return 0 + } + } + return 1 +} + ClassMethod IsSchemaStandard(pName As %String = "") As %Boolean [ Internal ] { Set parts = $Length(pName,".") diff --git a/cls/SourceControl/Git/WebUIDriver.cls b/cls/SourceControl/Git/WebUIDriver.cls index a7d808cc..f33950ac 100644 --- a/cls/SourceControl/Git/WebUIDriver.cls +++ b/cls/SourceControl/Git/WebUIDriver.cls @@ -18,6 +18,8 @@ ClassMethod HandleRequest(pagePath As %String, InternalName As %String = "", Out set responseJSON = ..Uncommitted() } elseif $extract(pagePath,6,*) = "settings" { set responseJSON = ..GetSettingsURL(%request) + } elseif $extract(pagePath, 6, *) = "oauth" { + set responseJSON = ..GetOAuthURL(%request) } elseif $extract(pagePath, 6, *) = "get-package-version"{ set responseJSON = ..GetPackageVersion() } elseif $extract(pagePath, 6, *) = "git-version" { @@ -436,6 +438,16 @@ ClassMethod GetSettingsURL(%request As %CSP.Request) As %SystemBase quit {"url": (settingsURL)} } +ClassMethod GetOAuthURL(%request As %CSP.Request) As %SystemBase +{ + set oauthURL = "" + #; if ('##class(SourceControl.Git.Utils).Authenticated()) { + set oauthURL = "/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp?CSPSHARE=1&Namespace="_$namespace_"&Username="_$username + set oauthURL = ..GetURLPrefix(%request, oauthURL) + #; } + quit {"url": (oauthURL)} +} + ClassMethod GetPackageVersion() As %Library.DynamicObject { set version = ##class(SourceControl.Git.Utils).GetPackageVersion() diff --git a/cls/_zpkg/isc/sc/git/Defaults.cls b/cls/_zpkg/isc/sc/git/Defaults.cls index 4ce7dc7c..d6a67eca 100644 --- a/cls/_zpkg/isc/sc/git/Defaults.cls +++ b/cls/_zpkg/isc/sc/git/Defaults.cls @@ -59,4 +59,4 @@ ClassMethod SetDefaultSettings(defaults As %Library.DynamicObject) As %Status [ return $$$OK } -} \ No newline at end of file +} diff --git a/cls/_zpkg/isc/sc/git/Favorites.cls b/cls/_zpkg/isc/sc/git/Favorites.cls index e1fb69ad..0f59e6b0 100644 --- a/cls/_zpkg/isc/sc/git/Favorites.cls +++ b/cls/_zpkg/isc/sc/git/Favorites.cls @@ -1,6 +1,7 @@ Class %zpkg.isc.sc.git.Favorites { - ClassMethod ConfigureFavoriteNamespaces(username As %String, newNamespaces As %Library.DynamicObject) + +ClassMethod ConfigureFavoriteNamespaces(username As %String, newNamespaces As %Library.DynamicObject) { // Convert to $listbuild set namespaces = $lb() @@ -16,7 +17,7 @@ Class %zpkg.isc.sc.git.Favorites } catch e { return e.AsStatus() } - return $$$OK + return $$$OK } ClassMethod GetFavoriteNamespaces(ByRef favNamespaces As %DynamicArray, ByRef nonFavNamespaces As %DynamicArray) @@ -31,7 +32,8 @@ ClassMethod GetFavoriteNamespaces(ByRef favNamespaces As %DynamicArray, ByRef no return $$$OK } -ClassMethod GetFavs() As %Library.DynamicObject [ Private, NotInheritable ] { +ClassMethod GetFavs() As %Library.DynamicObject [ NotInheritable, Private ] +{ $$$AddAllRoleTemporary set allNamespaces = ##class(SourceControl.Git.Utils).GetContexts(1) @@ -65,7 +67,8 @@ ClassMethod GetFavs() As %Library.DynamicObject [ Private, NotInheritable ] { return {"Favorites": (favNamespaces), "NonFavorites": (nonFavNamespaces)} } -ClassMethod SetFavs(username As %String, namespaces As %List) [ Private, NotInheritable ] { +ClassMethod SetFavs(username As %String, namespaces As %List) [ NotInheritable, Private ] +{ $$$AddAllRoleTemporary &sql(DELETE FROM %SYS_Portal.Users WHERE Username = :username AND Page LIKE '%Git%') @@ -86,4 +89,5 @@ ClassMethod SetFavs(username As %String, namespaces As %List) [ Private, NotInhe } } } -} \ No newline at end of file + +} diff --git a/cls/_zpkg/isc/sc/git/SSLConfig.cls b/cls/_zpkg/isc/sc/git/SSLConfig.cls new file mode 100644 index 00000000..772bbc48 --- /dev/null +++ b/cls/_zpkg/isc/sc/git/SSLConfig.cls @@ -0,0 +1,43 @@ +Class %zpkg.isc.sc.git.SSLConfig +{ + +ClassMethod CreateSSLConfigIfNonExistent(name As %String) +{ + try { + do ..CheckSSLConfig(name) + } catch e { + return e.AsStatus() + } + return $$$OK +} + +ClassMethod CheckSSLConfig(name As %String) [ NotInheritable, Private ] +{ + $$$AddAllRoleTemporary + new $namespace + set $namespace = "%SYS" + + do ##class(Security.SSLConfigs).Get(name, .p) + if $data(p) quit + + set p("CipherList")="ALL:!aNULL:!eNULL:!EXP:!SSLv2" + set p("CAFile")="" + set p("CAPath")="" + set p("CRLFile")="" + set p("CertificateFile")="" + set p("CipherList")="ALL:!aNULL:!eNULL:!EXP:!SSLv2" + set p("Description")="" + set p("Enabled")=1 + set p("PrivateKeyFile")="" + set p("PrivateKeyPassword")="" + set p("PrivateKeyType")=2 + set p("Protocols")=24 + set p("SNIName")="" + set p("Type")=0 + set p("VerifyDepth")=9 + set p("VerifyPeer")=0 + + do ##class(Security.SSLConfigs).Create(name, .p) +} + +} diff --git a/cls/_zpkg/isc/sc/git/Socket.cls b/cls/_zpkg/isc/sc/git/Socket.cls index a559965e..f65c3c92 100644 --- a/cls/_zpkg/isc/sc/git/Socket.cls +++ b/cls/_zpkg/isc/sc/git/Socket.cls @@ -39,14 +39,14 @@ ClassMethod Run() do ##class(SourceControl.Git.Utils).RunGitCommandWithInput("config",,,,"--global", "--add", "safe.directory", root) } - Do ##class(SourceControl.Git.Utils).Init() + $$$ThrowOnError(##class(SourceControl.Git.Utils).Init()) Write !,"Done." } ElseIf %request.Get("method") = "clone" { Set remote = %request.Get("remote") - Do ##class(SourceControl.Git.Utils).Clone(remote) + $$$ThrowOnError(##class(SourceControl.Git.Utils).Clone(remote)) Write !,"Done." } ElseIf %request.Get("method") = "sshkeygen" { - Do ##class(SourceControl.Git.Utils).GenerateSSHKeyPair() + $$$ThrowOnError(##class(SourceControl.Git.Utils).GenerateSSHKeyPair()) Write !,"Done." } Else { Write !!,"Invalid method selected.",!! diff --git a/cls/_zpkg/isc/sc/git/SystemMode.cls b/cls/_zpkg/isc/sc/git/SystemMode.cls index 54ca3951..d6dbd7eb 100644 --- a/cls/_zpkg/isc/sc/git/SystemMode.cls +++ b/cls/_zpkg/isc/sc/git/SystemMode.cls @@ -1,6 +1,7 @@ Class %zpkg.isc.sc.git.SystemMode { - ClassMethod SetEnvironment(environment As %String) As %Status [ NotInheritable, Private ] + +ClassMethod SetEnvironment(environment As %String) As %Status [ NotInheritable, Private ] { $$$AddAllRoleTemporary do $SYSTEM.Version.SystemMode(environment) @@ -20,4 +21,5 @@ ClassMethod SetSystemMode(environment As %String) As %Status [ NotInheritable ] } return $$$OK } -} \ No newline at end of file + +} diff --git a/csp/gitprojectsettings.csp b/csp/gitprojectsettings.csp index 40296ada..d2925963 100644 --- a/csp/gitprojectsettings.csp +++ b/csp/gitprojectsettings.csp @@ -95,125 +95,161 @@ body { - set namespace = $namespace - set version = ##class(SourceControl.Git.Utils).GetPackageVersion() - set webuiURL = "/isc/studio/usertemplates/gitsourcecontrol/webuidriver.csp/"_namespace_"/?CSPSHARE=1" - set webuiURL = ##class(SourceControl.Git.WebUIDriver).GetURLPrefix(%request, webuiURL) - set homeURL = ##class(SourceControl.Git.WebUIDriver).GetHomeURL() - - set settings = ##class(SourceControl.Git.Settings).%New() - try { - /// After Save - if (%request.Method="POST") && $Data(%request.Data("gitsettings",1)) { - for param="gitUserName","gitUserEmail" { - set $Property(settings,param) = $Get(%request.Data(param,1)) - } - - // Users may set basicMode even if settingsUIReadOnly is true - if ($Get(%request.Data("basicMode", 1)) = 1) { - set settings.basicMode = 1 - } elseif ($Get(%request.Data("basicMode", 1)) = "system"){ - set settings.basicMode = "system" - } else { - set settings.basicMode = 0 - } - - if ('settings.settingsUIReadOnly) { - do ##class(SourceControl.Git.Utils).Locked($get(%request.Data("lockNamespace",1))) - for param="gitBinPath","namespaceTemp","privateKeyFile","pullEventClass","percentClassReplace", "defaultMergeBranch","environmentName","mappingsToken","sshConfigFile" { + set namespace = $namespace + set version = ##class(SourceControl.Git.Utils).GetPackageVersion() + set webuiURL = "/isc/studio/usertemplates/gitsourcecontrol/webuidriver.csp/"_namespace_"/?CSPSHARE=1" + set webuiURL = ##class(SourceControl.Git.WebUIDriver).GetURLPrefix(%request, webuiURL) + set homeURL = ##class(SourceControl.Git.WebUIDriver).GetHomeURL() + + set settings = ##class(SourceControl.Git.Settings).%New() + try { + /// After Save + if (%request.Method="POST") && $Data(%request.Data("gitsettings",1)) { + for param="gitUserName","gitUserEmail" { set $Property(settings,param) = $Get(%request.Data(param,1)) } - if ($Get(%request.Data("mappedItemsReadOnly", 1)) = 1) { - set settings.mappedItemsReadOnly = 1 + // Users may set basicMode even if settingsUIReadOnly is true + if ($Get(%request.Data("basicMode", 1)) = 1) { + set settings.basicMode = 1 + } elseif ($Get(%request.Data("basicMode", 1)) = "system"){ + set settings.basicMode = "system" } else { - set settings.mappedItemsReadOnly = 0 + set settings.basicMode = 0 } - if ($Get(%request.Data("generatedFilesReadOnly", 1)) = 1) { - set settings.generatedFilesReadOnly = 1 - } else { - set settings.generatedFilesReadOnly = 0 - } + if ('settings.settingsUIReadOnly) { + do ##class(SourceControl.Git.Utils).Locked($get(%request.Data("lockNamespace",1))) + for param="gitBinPath","namespaceTemp","privateKeyFile","pullEventClass","percentClassReplace", "defaultMergeBranch","environmentName","mappingsToken","sshConfigFile" { + set $Property(settings,param) = $Get(%request.Data(param,1)) + } + if ($Get(%request.Data("mappedItemsReadOnly", 1)) = 1) { + set settings.mappedItemsReadOnly = 1 + } else { + set settings.mappedItemsReadOnly = 0 + } - set settings.compileOnImport = ($Get(%request.Data("compileOnImport", 1)) = 1) - set settings.decomposeProductions = ($Get(%request.Data("decomposeProductions", 1)) = 1) - set settings.decomposeProdAllowIDE = ($Get(%request.Data("decomposeProdAllowIDE", 1)) = 1) - set settings.lockBranch = ($Get(%request.Data("lockBranch", 1)) = 1) + if ($Get(%request.Data("generatedFilesReadOnly", 1)) = 1) { + set settings.generatedFilesReadOnly = 1 + } else { + set settings.generatedFilesReadOnly = 0 + } - if ($Get(%request.Data("systemBasicMode", 1)) = 1) { - set settings.systemBasicMode = 1 - } else { - set settings.systemBasicMode = 0 - } - set i = 1 - set param = "NoFolders" - kill settings.Mappings - - while ( $Data(%request.Data("MappingsExt",i)) ){ - if ($get(%request.Data("MappingsExt",i)) '= "") { - if ($Get(%request.Data(param,i)) = "NoFolders"){ - set settings.Mappings($Get(%request.Data("MappingsExt",i)), $Get(%request.Data("MappingsCov",i)), $Get(%request.Data(param,i))) = 1 - } - set settings.Mappings($Get(%request.Data("MappingsExt",i)), $Get(%request.Data("MappingsCov",i))) = $Get(%request.Data("MappingsPath",i)) - } + // Determine remote URL to check (from request or current settings) + if (remoteToCheck = "") { + // If remote wasn't in request, get from git config + set remoteToCheck = ##class(SourceControl.Git.Utils).GetConfiguredRemote() + } + + // Update gitRemoteType property based on remote URL + if (remoteToCheck '= "") { + set settings.gitRemoteType = ##class(SourceControl.Git.Settings).GetRemoteType(remoteToCheck) + } + + // Set OAuth based on remote URL protocol and toggle state + set settings.OAuth = $Get(%request.Data("OAuth", 1), 0) + + set settings.compileOnImport = ($Get(%request.Data("compileOnImport", 1)) = 1) + set settings.decomposeProductions = ($Get(%request.Data("decomposeProductions", 1)) = 1) + set settings.decomposeProdAllowIDE = ($Get(%request.Data("decomposeProdAllowIDE", 1)) = 1) + set settings.lockBranch = ($Get(%request.Data("lockBranch", 1)) = 1) + + if ($Get(%request.Data("systemBasicMode", 1)) = 1) { + set settings.systemBasicMode = 1 + } else { + set settings.systemBasicMode = 0 + } + set i = 1 + set param = "NoFolders" + kill settings.Mappings + + while ( $Data(%request.Data("MappingsExt",i)) ){ + if ($get(%request.Data("MappingsExt",i)) '= "") { + if ($Get(%request.Data(param,i)) = "NoFolders"){ + set settings.Mappings($Get(%request.Data("MappingsExt",i)), $Get(%request.Data("MappingsCov",i)), $Get(%request.Data(param,i))) = 1 + } + set settings.Mappings($Get(%request.Data("MappingsExt",i)), $Get(%request.Data("MappingsCov",i))) = $Get(%request.Data("MappingsPath",i)) + } + set i = i+1 + } + + set i = 1 + set contexts = [] + + while ( $Data(%request.Data("favNamespace",i)) ){ + if ($Get(%request.Data("favNamespace",i)) '= "") { + do contexts.%Push($Get(%request.Data("favNamespace",i))) + } set i = i+1 - } + } - set i = 1 - set contexts = [] + set settings.favoriteNamespaces = contexts + + if ($get(%request.Data("proxySubmitButton",1)) = "saveDefaults") { + do settings.SaveDefaults() + } + $$$ThrowOnError(settings.%Save()) - while ( $Data(%request.Data("favNamespace",i)) ){ - if ($Get(%request.Data("favNamespace",i)) '= "") { - do contexts.%Push($Get(%request.Data("favNamespace",i))) } - set i = i+1 + set err = "" + try { + set buffer = ##class(SourceControl.Git.Util.Buffer).%New() + do buffer.BeginCaptureOutput() + $$$ThrowOnError(settings.SaveWithSourceControl()) + do buffer.EndCaptureOutput(.out) + if (out '= "") { + &html<

+
#(..EscapeHTML(out))#
+
> + } + } catch err { + kill buffer + throw err } + set successfullySavedSettings = 1 + } - set settings.favoriteNamespaces = contexts + /// Handle OAuth deletion request + if (%request.Method="POST") && $Data(%request.Data("deleteOAuth",1)) { - if ($get(%request.Data("proxySubmitButton",1)) = "saveDefaults") { - do settings.SaveDefaults() - } + set status = ##class(SourceControl.Git.OAuth2).DeleteOAuthConfig($USERNAME) + // todo error handling + + // Disable OAuth in settings + set settings.OAuth = 0 + do settings.%Save() + + set oauthDeleted = 1 } - set err = "" - try { - set buffer = ##class(SourceControl.Git.Util.Buffer).%New() - do buffer.BeginCaptureOutput() - $$$ThrowOnError(settings.SaveWithSourceControl()) - do buffer.EndCaptureOutput(.out) - if (out '= "") { - &html<
-
#(..EscapeHTML(out))#
-
> - } - } catch err { - kill buffer - throw err + + set remote = ##class(SourceControl.Git.Utils).GetRedactedRemote() + if (remote'="") && (##class(SourceControl.Git.Utils).RunGitCommandWithInput("ls-remote",,.errStream,,"origin")'=0) { + set remoteConnectionError = errStream.Read() } - // Do not attempt to set a new remote repo if 'remoteRepo' is not actually present in the request - // This may occur for example when settingsUIReadOnly is true where 'remoteRepo' is not user editable - if ($data(%request.Data("remoteRepo",1)) '= 0) { - set newRemote = $Get(%request.Data("remoteRepo",1)) - // If entry was modified and git repo is initialized, set new remote repo - set dir = ##class(%File).NormalizeDirectory(settings.namespaceTemp) - if (settings.namespaceTemp '= "") && ##class(%File).DirectoryExists(dir_".git") { - if (newRemote '= ##class(SourceControl.Git.Utils).GetRedactedRemote()) { - do ##class(SourceControl.Git.Utils).SetConfiguredRemote(newRemote) - } - } + } catch err { + do err.Log() + &html<
An error occurred and has been logged to the application error log.
> + if ($get(%request.Data("proxySubmitButton",1)) = "saveDefaults") { + do settings.SaveDefaults() } - set successfullySavedSettings = 1 } - set remote = ##class(SourceControl.Git.Utils).GetRedactedRemote() - if (remote'="") && (##class(SourceControl.Git.Utils).RunGitCommandWithInput("ls-remote",,.errStream,,"origin")'=0) { - set remoteConnectionError = errStream.Read() + set err = "" + try { + set buffer = ##class(SourceControl.Git.Util.Buffer).%New() + do buffer.BeginCaptureOutput() + $$$ThrowOnError(settings.SaveWithSourceControl()) + do buffer.EndCaptureOutput(.out) + if (out '= "") { + &html<
+
#(..EscapeHTML(out))#
+
> + } + } catch err { + kill buffer + do err.Log() + &html<
An error occurred and has been logged to the application error log.
> } -} catch err { - do err.Log() - &html<
An error occurred and has been logged to the application error log.
> -}
@@ -230,6 +266,12 @@ body { Success! Your changes have been saved.
+ +
+ × + Success! OAuth configuration has been deleted. +
+
@@ -487,8 +529,74 @@ body { Connection successful + - + +
+ +
+ + // Check if OAuth is configured (has access token) + set hasOAuthToken = (##class(SourceControl.Git.OAuth2).GetToken() '= "") + if (hasOAuthToken) { + &html<✓ OAuth Configured> + } else { + &html<⚠ OAuth Not Configured> + } + +
+ + + if ('hasOAuthToken) { + &html<Important: To clone/access HTTPS repositories with OAuth authentication, you must + Configure OAuth Now + before enabling the OAuth toggle below.> + } else { + &html + } + + +
+
+ +
+ +
+
+ + +
+ + Enable this to use OAuth2 authentication for cloning and accessing HTTPS repositories (GitHub/GitLab). +
+ + set hasOAuthToken = (##class(SourceControl.Git.OAuth2).GetToken() '= "") + if ('hasOAuthToken) { + &html<⚠ Action Required: You must + configure your OAuth credentials + before this will work.> + } else { + &html<✓ OAuth is configured and ready.> + } + +
+
+
+ +
+
+ + set hasOAuthConfig = (##class(SourceControl.Git.OAuth2.Config).GetConfig($username) '= "") + if (hasOAuthConfig) { + &html<> + } + + + This will permanently remove all OAuth credentials and configuration for this user. + +
+
+
@@ -827,6 +935,25 @@ var submitForm = function(e) { form.submit(); } +/*const remoteURL = document.getElementById('remoteRepo'); +if ((remoteURL.value.toLowerCase().includes('https'))) { + document.getElementById('oauthStatus').style.display = 'flex'; + document.getElementById('remoteOAuth').style.display = 'flex'; + document.getElementById('deleteOAuthSection').style.display = 'flex'; +} +remoteURL.addEventListener('change', function () { + console.log(remoteURL.value) + if ((remoteURL.value.toLowerCase().includes('https'))) { + document.getElementById('oauthStatus').style.display = 'flex'; + document.getElementById('remoteOAuth').style.display = 'flex'; + document.getElementById('deleteOAuthSection').style.display = 'flex'; + } else { + document.getElementById('oauthStatus').style.display = 'none'; + document.getElementById('remoteOAuth').style.display = 'none'; + document.getElementById('deleteOAuthSection').style.display = 'none'; + } +});*/ + document.getElementById('saveDefaults').addEventListener('click',submitForm,false); function init() { @@ -841,6 +968,30 @@ function clone() { if ((remote == null) || (remote == "")) { return; } + + // Check if remote is HTTPS + var isHTTPS = remote.toLowerCase().startsWith('https://'); + + // Check if OAuth is enabled in settings + var oauthEnabled = document.getElementById('OAuth') && document.getElementById('OAuth').checked; + + // Warn if trying to clone HTTPS with OAuth enabled but not configured + if (isHTTPS && oauthEnabled) { + // This check will be done server-side, but we can pre-warn here + var proceed = confirm( + "You are cloning an HTTPS repository with OAuth authentication.\n\n" + + "Make sure you have configured your OAuth credentials.\n\n" + + "Continue with clone?" + ); + if (!proceed) { + return; + } + } + + // Show immediate feedback + var initOutput = document.getElementById('initOutput'); + initOutput.innerHTML = '
Clone in progress... This may take a few minutes.
'; + disableActionButtons(); var formGroupRemoteRepo = document.getElementById("formGroupRemoteRepo"); var namespaceSettings = document.getElementById("namespaceSettings"); @@ -981,13 +1132,37 @@ function toggleNoFolders(e){ $('[id^=noFoldersSwitch]').click(toggleNoFolders); -// Check to persist state of no folder switches +// Check to persist state of no folder switches $('.mapping-input-group').children('.voca').each(function(){ var currElement = $(this).children().children(".custom-control").children()[0] if(!$(currElement).hasClass("active")) { $(currElement).parent().siblings("#NoFolders")[0].value = "NoFolders"; } }); + +// Handle OAuth deletion button +$(document).on('click', '#deleteOAuthBtn', function(e) { + e.preventDefault(); + var confirmed = confirm('Are you sure you want to delete your OAuth configuration? This will remove all stored credentials.'); + if (confirmed) { + // Create a form dynamically and submit it + var form = $('', { + 'method': 'post', + 'action': window.location.href + }); + $('').attr({ + type: 'hidden', + name: 'deleteOAuth', + value: '1' + }).appendTo(form); + $('').attr({ + type: 'hidden', + name: 'gitsettings', + value: '1' + }).appendTo(form); + form.appendTo('body').submit(); + } +}); diff --git a/csp/oauth2.csp b/csp/oauth2.csp new file mode 100644 index 00000000..237914d4 --- /dev/null +++ b/csp/oauth2.csp @@ -0,0 +1,392 @@ + + + + + + +HTTPS OAuth Configuration + + + + + + + set failed = 0 + set authenticated = 0 + // Check if this is an OAuth callback from GitHub (has state parameter) + set state = $Get(%request.Data("state",1)) + if state '= "" { + // Redirected here from github + // switch to the namespace that the extension is installed to + new $namespace + set $namespace = $get(%request.Data("Namespace",1), $namespace) + + set config = ##class(SourceControl.Git.OAuth2.Config).GetConfig($username) + set configStage = ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair($username,"state", .error, .statusCode) + if (statusCode '= 1) || (error '= "") { + set failed = 1 + set failuremessage = "Failed to get State with error: " _ error + } + if (configStage '= state){ + set failed =1 + set failuremessage = "Invalid state" + quit 1 + } + + // Get the authorization code from GitHub's callback + set authCode = $Get(%request.Data("code",1)) + if authCode = "" { + set failed =1 + set failuremessage = "Invalid request parameters - missing authorization code" + quit 1 + } + + //set verifier = config.Verifier + set verifier = ##class(SourceControl.Git.Util.CredentialManager).GetKeyPair($username,"verifier", .error, .statusCode) + if (statusCode '= 1) || (error '= "") { + set failed = 1 + set failuremessage = "Failed to get Verifier with error: " _ error + } + + set result = config.Exchange(authCode, verifier, .sc) + if sc '= $$$OK { + do $SYSTEM.Status.DisplayError(sc) + set failed = 1 + set failuremessage = "Unable to retrieve access token" + } else { + do ##class(SourceControl.Git.Util.CredentialManager).SetAccessToken($username, result, .error, .code) + if (code '= 1) || (error '= "") { + set failed = 1 + set failuremessage = "Unable to save credentials" + } else { + if (##class(SourceControl.Git.OAuth2).GetToken() '= "") { + set authenticated = 1 + } else { + set failed = 1 + set failuremessage = "Something went wrong" + } + } + } + } + set namespace = $NAMESPACE + set username = $USERNAME + + // Generate URLs for navigation buttons + set webuiURL = "/isc/studio/usertemplates/gitsourcecontrol/webuidriver.csp/"_namespace_"/?CSPSHARE=1" + set webuiURL = ##class(SourceControl.Git.WebUIDriver).GetURLPrefix(%request, webuiURL) + set homeURL = ##class(SourceControl.Git.WebUIDriver).GetHomeURL() + + set config = ##class(SourceControl.Git.OAuth2.Config).GetConfig(username) + if (config = "") { + set authURL = "" + set tokenURL = "" + set remote = ##class(SourceControl.Git.Utils).GetConfiguredRemote() + set urls = ##class(SourceControl.Git.OAuth2).GetURLsFromRemote(remote,.authURL,.tokenURL) + } + set authCodeURL = "" + set ready = "" + /// After submit + if (%request.Method="POST") && $Data(%request.Data("oauthsettings",1)) { + set ready = 1 + set clientID = $Get(%request.Data("clientID",1)) + set clientSecret = $Get(%request.Data("clientSecret",1)) + set authURL = $Get(%request.Data("authURL",1)) + set tokenURL = $Get(%request.Data("tokenURL",1)) + set redirect = $piece($Get(%request.Data("redirectURL",1)),"?",1) + set usingDirectToken = $Get(%request.Data("usingDirectToken"), 0) + set directToken = $Get(%request.Data("directToken", 1)) + + /// This make sure private memory store works + do ##class(SourceControl.Git.Util.CredentialManager).StopMemoryStore() + + if config = "" { + set config = ##class(SourceControl.Git.OAuth2.Config).%New(username, clientID, clientSecret,authURL,tokenURL,redirect) + } else { + set config.ClientID = clientID + set config.ClientSecret = clientSecret + set config.Endpoint.AuthURL = authURL + set config.Endpoint.TokenURL = tokenURL + set config.RedirectURL = redirect + set config.DirectToken = $select(usingDirectToken = 1: 1, 1: 0) + } + + do config.%Save() + if (usingDirectToken '= 1) { + set authCodeURL = ##class(SourceControl.Git.OAuth2).AuthCodeURL(config,namespace,.state,.verifier) + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair($username, "state", state, .error, .code) + if (code '= 1) || (error '= "") { + set failed = 1 + set failuremessage = "Set failed with following error: "_error + } + do ##class(SourceControl.Git.Util.CredentialManager).SetKeyPair($username, "verifier", verifier, .error, .code) + if (code '= 1) || (error '= "") { + set failed = 1 + set failuremessage = "Set failed with following error: "_error + } + } else { + do ##class(SourceControl.Git.Util.CredentialManager).SetAccessToken($username, directToken, .error, .code) + set ready = "" + set authenticated = 1 + } + do config.%Save() + } + +
+ +
+ × + Success! You have been authenticated. +
+
+ +
+ × + Error! #(failuremessage)# +
+
+ +
+ +
+ #(homeURL)# +
+ +
+ #(webuiURL)# +
+
+ + +
+

+ To connect your GitHub or GitLab repository, you need to generate a Client ID and Client Secret. + Follow these steps: +

+ +

Once generated, enter your Client ID and Client Secret below:

+

The "Authorization callback URL" should be: text

+
+
+ +
+
+ + set directToken = $select(config:config.DirectToken, 1: 0) + if (directToken) { + &html<> + } else { + &html<> + } + + +
+
+
+ + +
+
+ +
+ + set clientID = $select(config:config.ClientID, 1: "") + + +
+
+ +
+ +
+ + set clientSecret = $select(config:config.ClientSecret, 1: "") + + +
+
+ +
+ +
+ + set authURL = $select(config:config.Endpoint.AuthURL,authURL'="":authURL, 1: "") + + +
+
+ +
+ +
+ + set tokenURL = $select(config:config.Endpoint.TokenURL, tokenURL'="":tokenURL, 1: "") + + +
+
+
+ +
+
+ + +
+
+ + + + + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/docs/https.md b/docs/https.md new file mode 100644 index 00000000..2a97249f --- /dev/null +++ b/docs/https.md @@ -0,0 +1,18 @@ +## Setting up HTTPS + +We highly recommend that you use SSH to connect to your repositories. If this is not possible, then HTTPS is another option. + +First, add your remote repo in the settings page, or during the Configure step. (Note: do NOT provide a username in the url) + +After this, you have to authenticate using OAuth tokens. To do this, press "Authenticate" in the bottom left of the Embedded Git UI, or from the Source Control Menu. + +### Authentication + +If you have not already done so, create a new OAuth app in github or gitlab. The "Authorization callback URL" should be <your url>/isc/studio/usertemplates/gitsourcecontrol/oauth2.csp. + +Remember to save the ClientID and ClientSecret. Once this is finished, you can enter your information into the authentication page. +![Screenshot of authentication page](images/auth.png) + +Once all of the information is correct, you can press Save. This will redirect you to either gitlab or github in order to authorize your application. After this is done, you will be redirected back to the authentication page, and you should be good to go! + + diff --git a/docs/images/auth.png b/docs/images/auth.png new file mode 100644 index 00000000..e6f7e7ac Binary files /dev/null and b/docs/images/auth.png differ diff --git a/git-webui/release/share/git-webui/webui/css/git-webui.css b/git-webui/release/share/git-webui/webui/css/git-webui.css index d5958433..74c8349c 100644 --- a/git-webui/release/share/git-webui/webui/css/git-webui.css +++ b/git-webui/release/share/git-webui/webui/css/git-webui.css @@ -251,6 +251,22 @@ body { width: 16.4em; background-color: #333333; } +#sidebar #sidebar-content #sidebar-oauth a { + color: white; +} +#sidebar #sidebar-content #sidebar-oauth h4 { + padding: 0px; + margin-bottom: 10px; +} +#sidebar #sidebar-content #sidebar-oauth h4:before { + content: url(../img/oauth.svg); +} +#sidebar #sidebar-content #sidebar-oauth { + position: absolute; + bottom: 120px; + width: 16.4em; + background-color: #333333; +} #sidebar #sidebar-content #sidebar-context h4 { padding: 0px; margin-bottom: 10px; diff --git a/git-webui/release/share/git-webui/webui/img/oauth.svg b/git-webui/release/share/git-webui/webui/img/oauth.svg new file mode 100644 index 00000000..b2c4fa95 --- /dev/null +++ b/git-webui/release/share/git-webui/webui/img/oauth.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/git-webui/release/share/git-webui/webui/js/git-webui.js b/git-webui/release/share/git-webui/webui/js/git-webui.js index 53cfdfe7..e9065bea 100644 --- a/git-webui/release/share/git-webui/webui/js/git-webui.js +++ b/git-webui/release/share/git-webui/webui/js/git-webui.js @@ -942,6 +942,10 @@ webui.SideBarView = function(mainView, noEventHandlers) { window.location.href = webui.settingsURL; } + self.goToOAuth = function() { + window.location.href = webui.oauthURL; + } + self.goToHomePage = function() { window.location.href = webui.homeURL; } @@ -997,6 +1001,12 @@ webui.SideBarView = function(mainView, noEventHandlers) { self.mainView = mainView; self.currentContext = self.getCurrentContext(); + + var oauthHTML = ''; + if (webui.oauthURL != "") { + oauthHTML = '' + } + self.element = $( '