Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ apply from: "https://raw.githubusercontent.com/gocd/gocd-plugin-gradle-task-help

gocdPlugin {
id = 'cd.go.authorization.github'
pluginVersion = '3.4.0'
goCdVersion = '20.9.0'
pluginVersion = '4.0.0'
goCdVersion = '22.1.0'
name = 'GitHub OAuth authorization plugin'
description = 'GitHub OAuth authorization plugin for GoCD'
vendorName = 'Thoughtworks, Inc.'
Expand All @@ -44,6 +44,11 @@ gocdPlugin {
}
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

group = 'cd.go'
version = gocdPlugin.fullVersion(project)

Expand All @@ -52,17 +57,6 @@ repositories {
mavenLocal()
}

java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}

configurations.all {
resolutionStrategy.dependencySubstitution {
substitute module("junit:junit") using module("io.quarkus:quarkus-junit4-mock:3.0.0.Final") because "We don't want JUnit 4; but is an unneeded transitive of mockwebserver."
}
}

ext {
deps = [
gocdPluginApi: 'cd.go.plugin:go-plugin-api:25.2.0',
Expand All @@ -78,17 +72,18 @@ dependencies {
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.12.0'

testImplementation project.deps.gocdPluginApi
testImplementation group: 'org.mockito', name: 'mockito-core', version: '5.18.0'
testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '3.0'
testImplementation group: 'org.skyscreamer', name: 'jsonassert', version: '1.5.3'
testImplementation group: 'org.jsoup', name: 'jsoup', version: '1.21.1'
testImplementation group: 'com.squareup.okhttp3', name: 'mockwebserver', version: '4.12.0'

testImplementation platform('org.junit:junit-bom:5.13.2')
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api'
testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-params'
testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter-engine'
testRuntimeOnly group: 'org.junit.platform', name: 'junit-platform-launcher'
testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.27.3'
testImplementation group: 'org.mockito', name: 'mockito-core', version: '5.18.0'
testImplementation group: 'org.skyscreamer', name: 'jsonassert', version: '1.5.3'
testImplementation group: 'org.jsoup', name: 'jsoup', version: '1.21.1'
testImplementation group: 'com.squareup.okhttp3', name: 'mockwebserver3-junit5', version: '5.0.0-alpha.17'


}

test {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/cd/go/authorization/github/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ public interface Constants {
GoPluginIdentifier PLUGIN_IDENTIFIER = new GoPluginIdentifier(EXTENSION_TYPE, Collections.singletonList(API_VERSION));

String AUTH_SESSION_STATE = "oauth2_state";
String AUTH_CODE_VERIFIER_ENCODED = "oauth2_code_verifier_encoded";
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@

import cd.go.authorization.github.models.AuthenticateWith;
import cd.go.authorization.github.models.GitHubConfiguration;
import okhttp3.FormBody;
import okhttp3.HttpUrl;
import okhttp3.Request;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;
import org.kohsuke.github.GitHubRateLimitHandler;

import java.io.IOException;
import java.util.List;

import static cd.go.authorization.github.GitHubPlugin.LOG;

Expand All @@ -48,4 +52,44 @@ private GitHub clientFor(String personalAccessTokenOrUsersAccessToken, GitHubCon
.build();
}
}

public List<String> authorizationServerArgs(GitHubConfiguration config, String callbackUrl) {
String state = StateGenerator.generate();
ProofKey proofKey = new ProofKey();
String authorizationServerUrl = HttpUrl.parse(config.authenticateWith() == AuthenticateWith.GITHUB ? GitHubConfiguration.GITHUB_URL : config.gitHubEnterpriseUrl())
.newBuilder()
.addPathSegment("login")
.addPathSegment("oauth")
.addPathSegment("authorize")
.addQueryParameter("client_id", config.clientId())
.addQueryParameter("redirect_uri", callbackUrl)
.addQueryParameter("response_type", "code")
.addQueryParameter("scope", config.scope())
.addQueryParameter("state", state)
.addQueryParameter("code_challenge_method", "S256")
.addEncodedQueryParameter("code_challenge", proofKey.codeChallengeEncoded())
.build().toString();
return List.of(authorizationServerUrl, state, proofKey.codeVerifierEncoded());
}

public Request accessTokenRequestFrom(GitHubConfiguration config, String authorizationCode, String codeVerifierEncoded) {
HttpUrl accessTokenUrl = HttpUrl.parse(config.authenticateWith() == AuthenticateWith.GITHUB ? GitHubConfiguration.GITHUB_URL : config.gitHubEnterpriseUrl())
.newBuilder()
.addPathSegment("login")
.addPathSegment("oauth")
.addPathSegment("access_token")
.build();

return new Request.Builder()
.url(accessTokenUrl)
.addHeader("Accept", "application/json")
.post(new FormBody.Builder()
.add("client_id", config.clientId())
.add("client_secret", config.clientSecret())
.add("code", authorizationCode)
.add("code_verifier", codeVerifierEncoded)
.build())
.build();
}

}
61 changes: 61 additions & 0 deletions src/main/java/cd/go/authorization/github/client/ProofKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2025 ThoughtWorks, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package cd.go.authorization.github.client;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;

public class ProofKey {
private static final SecureRandom RANDOM = new SecureRandom();

private final String codeVerifierEncoded;
private final String codeChallengeEncoded;

public ProofKey() {
this.codeVerifierEncoded = generateCodeVerifier();
this.codeChallengeEncoded = generateCodeChallenge(codeVerifierEncoded);
}

private static String generateCodeVerifier() {
byte[] codeVerifier = new byte[32];
RANDOM.nextBytes(codeVerifier);
return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
}

private static String generateCodeChallenge(String codeVerifier) {
byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII);
MessageDigest messageDigest;
try {
messageDigest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
messageDigest.update(bytes, 0, bytes.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(messageDigest.digest());
}

public String codeVerifierEncoded() {
return codeVerifierEncoded;
}

public String codeChallengeEncoded() {
return codeChallengeEncoded;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,47 @@

package cd.go.authorization.github.executors;

import cd.go.authorization.github.client.GitHubClientBuilder;
import cd.go.authorization.github.exceptions.AuthenticationException;
import cd.go.authorization.github.exceptions.NoAuthorizationConfigurationException;
import cd.go.authorization.github.models.AuthConfig;
import cd.go.authorization.github.models.GitHubConfiguration;
import cd.go.authorization.github.models.TokenInfo;
import cd.go.authorization.github.requests.FetchAccessTokenRequest;
import com.thoughtworks.go.plugin.api.response.DefaultGoPluginApiResponse;
import com.thoughtworks.go.plugin.api.response.GoPluginApiResponse;
import okhttp3.*;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

import static cd.go.authorization.github.GitHubPlugin.LOG;
import static java.text.MessageFormat.format;

public class FetchAccessTokenRequestExecutor implements RequestExecutor {
private final FetchAccessTokenRequest request;
private final OkHttpClient httpClient;
private GitHubClientBuilder gitHubClientBuilder;

public FetchAccessTokenRequestExecutor(FetchAccessTokenRequest request) {
this(request, new OkHttpClient());
this(request, new OkHttpClient(), new GitHubClientBuilder());
}

FetchAccessTokenRequestExecutor(FetchAccessTokenRequest request, OkHttpClient httpClient) {
FetchAccessTokenRequestExecutor(FetchAccessTokenRequest request, OkHttpClient httpClient, GitHubClientBuilder gitHubClientBuilder) {
this.request = request;
this.httpClient = httpClient;
this.gitHubClientBuilder = gitHubClientBuilder;
}

public GoPluginApiResponse execute() throws Exception {
if (request.authConfigs() == null || request.authConfigs().isEmpty()) {
throw new NoAuthorizationConfigurationException("[Get Access Token] No authorization configuration found.");
}

if (!request.requestParameters().containsKey("code")) {
throw new IllegalArgumentException("Get Access Token] Expecting `code` in request params, but not received.");
}

request.validateState();

final AuthConfig authConfig = request.authConfigs().get(0);
final GitHubConfiguration gitHubConfiguration = authConfig.gitHubConfiguration();

final Request fetchAccessTokenRequest = accessTokenRequestFrom(gitHubConfiguration);

final Response response = httpClient.newCall(fetchAccessTokenRequest).execute();
final Request request = gitHubClientBuilder.accessTokenRequestFrom(authConfig.gitHubConfiguration(), this.request.authorizationCode(), this.request.codeVerifierEncoded());
final Response response = httpClient.newCall(request).execute();
if (response.isSuccessful()) {
LOG.info("[Get Access Token] Access token fetched successfully.");
final TokenInfo tokenInfo = TokenInfo.fromJSON(response.body().string());
Expand All @@ -68,24 +66,5 @@ public GoPluginApiResponse execute() throws Exception {
throw new AuthenticationException(format("[Get Access Token] {0}", response.message()));
}

private Request accessTokenRequestFrom(GitHubConfiguration gitHubConfiguration) {
return new Request.Builder()
.url(accessTokenUrl(gitHubConfiguration))
.addHeader("Accept", "application/json")
.post(new FormBody.Builder()
.add("client_id", gitHubConfiguration.clientId())
.add("client_secret", gitHubConfiguration.clientSecret())
.add("code", request.requestParameters().get("code"))
.build())
.build();
}

private HttpUrl accessTokenUrl(GitHubConfiguration gitHubConfiguration) {
return HttpUrl.parse(gitHubConfiguration.apiUrl())
.newBuilder()
.addPathSegment("login")
.addPathSegment("oauth")
.addPathSegment("access_token")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,31 @@
package cd.go.authorization.github.executors;

import cd.go.authorization.github.Constants;
import cd.go.authorization.github.client.StateGenerator;
import cd.go.authorization.github.client.GitHubClientBuilder;
import cd.go.authorization.github.exceptions.NoAuthorizationConfigurationException;
import cd.go.authorization.github.models.AuthConfig;
import cd.go.authorization.github.models.GitHubConfiguration;
import cd.go.authorization.github.requests.GetAuthorizationServerUrlRequest;
import com.thoughtworks.go.plugin.api.response.DefaultGoPluginApiResponse;
import com.thoughtworks.go.plugin.api.response.GoPluginApiResponse;
import okhttp3.HttpUrl;

import java.util.List;
import java.util.Map;

import static cd.go.authorization.github.GitHubPlugin.LOG;
import static cd.go.authorization.github.utils.Util.GSON;

public class GetAuthorizationServerUrlRequestExecutor implements RequestExecutor {
private final GetAuthorizationServerUrlRequest request;
private final GitHubClientBuilder gitHubClientBuilder;

public GetAuthorizationServerUrlRequestExecutor(GetAuthorizationServerUrlRequest request) {
this(request, new GitHubClientBuilder());
}

public GetAuthorizationServerUrlRequestExecutor(GetAuthorizationServerUrlRequest request, GitHubClientBuilder gitHubClientBuilder) {
this.request = request;
this.gitHubClientBuilder = gitHubClientBuilder;
}

public GoPluginApiResponse execute() throws Exception {
Expand All @@ -48,22 +54,11 @@ public GoPluginApiResponse execute() throws Exception {
final AuthConfig authConfig = request.authConfigs().get(0);
final GitHubConfiguration gitHubConfiguration = authConfig.gitHubConfiguration();

String state = StateGenerator.generate();
String authorizationServerUrl = HttpUrl.parse(gitHubConfiguration.apiUrl())
.newBuilder()
.addPathSegment("login")
.addPathSegment("oauth")
.addPathSegment("authorize")
.addQueryParameter("client_id", gitHubConfiguration.clientId())
.addQueryParameter("redirect_uri", request.callbackUrl())
.addQueryParameter("scope", gitHubConfiguration.scope())
.addQueryParameter("state", state)
.build().toString();

List<String> result = gitHubClientBuilder.authorizationServerArgs(gitHubConfiguration, request.callbackUrl());

return DefaultGoPluginApiResponse.success(GSON.toJson(Map.of(
"authorization_server_url", authorizationServerUrl,
"auth_session", Map.of(Constants.AUTH_SESSION_STATE, state)
"authorization_server_url", result.get(0),
"auth_session", Map.of(Constants.AUTH_SESSION_STATE, result.get(1), Constants.AUTH_CODE_VERIFIER_ENCODED, result.get(2))
)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import static cd.go.authorization.github.utils.Util.*;

public class GitHubConfiguration implements Validatable {
private static final String GITHUB_URL = "https://github.com";
public static final String GITHUB_URL = "https://github.com";
private static final String GITHUB_ENTERPRISE_API_SUFFIX = "/api/v3/";

@Expose
Expand Down Expand Up @@ -102,10 +102,6 @@ public String gitHubEnterpriseApiUrl() {
return gitHubEnterpriseUrl.concat(GITHUB_ENTERPRISE_API_SUFFIX);
}

public String apiUrl() {
return authenticateWith == AuthenticateWith.GITHUB ? GITHUB_URL : gitHubEnterpriseUrl;
}

public String scope() {
return "user:email";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,12 @@ public void validateState() {
throw new AuthenticationException("Redirected OAuth2 state from GitHub did not match previously generated state stored in session");
}
}

public String authorizationCode() {
return Objects.requireNonNullElseGet(requestParameters().get("code"), () -> { throw new IllegalArgumentException("[Get Access Token] Expecting `code` in request params, but not received."); });
}

public String codeVerifierEncoded() {
return Objects.requireNonNullElseGet(authSession.get(Constants.AUTH_CODE_VERIFIER_ENCODED), () -> { throw new IllegalArgumentException("[Get Access Token] OAuth2 code verifier is missing from session"); });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ public class UserAuthenticationRequest extends Request {
@SerializedName("auth_configs")
private List<AuthConfig> authConfigs;

@Expose
@SerializedName("role_configs")
private List<Role> roles;

@Expose
@SerializedName("credentials")
private TokenInfo tokenInfo;

@Expose
@SerializedName("role_configs")
private List<Role> roles;

public static UserAuthenticationRequest from(GoPluginApiRequest apiRequest) {
return Request.from(apiRequest, UserAuthenticationRequest.class);
}
Expand Down
Loading