Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.MediaType;
import io.micronaut.http.MutableHttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Header;
import io.micronaut.http.annotation.QueryValue;
import io.micronaut.http.context.ServerRequestContext;
import io.micronaut.http.cookie.Cookie;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -119,17 +121,34 @@ public HttpResponse<GitHubCreateDTO> createApp(
@Parameter(hidden = true) @NonNull RequestInfo requestInfo) {
URI launcherURI = redirectService.getLauncherURI();
try {
String oauthError = requestInfo.getParameters().get("error");
if (!StringUtils.isEmpty(oauthError)) {
String oauthErrorDescription = requestInfo.getParameters().get("error_description");
String error = StringUtils.isEmpty(oauthErrorDescription) ? oauthError : oauthErrorDescription;
return handleOAuthError(launcherURI, requestInfo, state, error);
}

if (StringUtils.isEmpty(code) && StringUtils.isEmpty(state)) {
String oauthState = redirectService.createOAuthState();
return HttpResponse.<GitHubCreateDTO>temporaryRedirect(redirectService.constructOAuthRedirectUrl(requestInfo, oauthState))
.cookie(redirectService.createOAuthStateCookie(requestInfo, oauthState, getStoredOAuthStates()));
}

if (StringUtils.isEmpty(code) || StringUtils.isEmpty(state)) {
return HttpResponse.temporaryRedirect(redirectService.constructOAuthRedirectUrl(requestInfo));
return rejectInvalidOAuthState(launcherURI);
}

if (!redirectService.isValidOAuthState(state, getStoredOAuthStates())) {
return rejectInvalidOAuthState(launcherURI);
}

GitHubRepository repository = gitHubCreateService.creatApp(
type, name, features, build, reloading, gorm, servlet, javaVersion, code, state, userAgent);

if (launcherURI == null) {
return consumeOAuthStateCookie(HttpResponse.ok(new GitHubCreateDTO(repository.getUrl(), repository.getCloneUrl(), repository.getHtmlUrl())), requestInfo, state);
} else {
GitHubRepository repository = gitHubCreateService.creatApp(
type, name, features, build, reloading, gorm, servlet, javaVersion, code, state, userAgent);

if (launcherURI == null) {
return HttpResponse.ok(new GitHubCreateDTO(repository.getUrl(), repository.getCloneUrl(), repository.getHtmlUrl()));
} else {
return HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeRedirectUrl(repository));
}
return consumeOAuthStateCookie(HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeRedirectUrl(repository)), requestInfo, state);
}
} catch (Exception e) {
if (LOG.isDebugEnabled()) {
Expand All @@ -139,43 +158,42 @@ public HttpResponse<GitHubCreateDTO> createApp(
if (launcherURI == null) {
throw e;
} else {
return HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeErrorRedirectUrl(e.getMessage()));
MutableHttpResponse<GitHubCreateDTO> response = HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeErrorRedirectUrl(e.getMessage()));
return hasValidOAuthState(state) ? consumeOAuthStateCookie(response, requestInfo, state) : response;
}
}
}

/**
* Endpoint handles GitHub OAuth authorisation errors.
*
* @param error error code
* @param errorDescription description
* @return Http redirects
* @see <a href="https://docs.github.com/en/free-pro-team@lareloading/developers/apps/troubleshooting-oauth-app-access-token-request-errors">Troubleshooting OAuth App access token request errors</a>
*/
@Get(uri = "/github{?error,error_description}", produces = MediaType.APPLICATION_JSON)
@ApiResponses(value = {
@ApiResponse(
responseCode = "307",
description = "Forwarded GitHub OAuth error message."
),
@ApiResponse(
responseCode = "200",
description = "Returns GitHub OAuth application callback error."
)})
public HttpResponse<String> handleCallback(
@Nullable String error,
@Nullable @QueryValue("error_description") String errorDescription) {
URI redirect;
if (!StringUtils.isEmpty(error)) {
redirect = redirectService.constructGrailsForgeErrorRedirectUrl(errorDescription);
} else {
redirect = redirectService.getLauncherURI();
private HttpResponse<GitHubCreateDTO> handleOAuthError(URI launcherURI, RequestInfo requestInfo, String state, String error) {
if (!hasValidOAuthState(state)) {
return rejectInvalidOAuthState(launcherURI);
}
if (launcherURI == null) {
return consumeOAuthStateCookie(HttpResponse.badRequest(), requestInfo, state);
}
return consumeOAuthStateCookie(HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeErrorRedirectUrl(error)), requestInfo, state);
}

if (redirect == null) {
return HttpResponse.ok(errorDescription);
} else {
return HttpResponse.temporaryRedirect(redirect);
private <T> HttpResponse<T> rejectInvalidOAuthState(URI launcherURI) {
if (launcherURI == null) {
return HttpResponse.badRequest();
}
IllegalArgumentException exception = new IllegalArgumentException("Invalid GitHub OAuth state.");
return HttpResponse.temporaryRedirect(redirectService.constructGrailsForgeErrorRedirectUrl(exception.getMessage()));
}

private <T> HttpResponse<T> consumeOAuthStateCookie(MutableHttpResponse<T> response, RequestInfo requestInfo, String state) {
return response.cookie(redirectService.consumeOAuthStateCookie(requestInfo, getStoredOAuthStates(), state));
}

private String getStoredOAuthStates() {
return ServerRequestContext.currentRequest()
.flatMap(request -> request.getCookies().findCookie(GitHubRedirectService.OAUTH_STATE_COOKIE))
.map(Cookie::getValue)
.orElse(null);
}

private boolean hasValidOAuthState(String state) {
return redirectService.isValidOAuthState(state, getStoredOAuthStates());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

import io.micronaut.context.annotation.Property;
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.cookie.Cookie;
import io.micronaut.http.cookie.SameSite;
import io.micronaut.http.uri.UriBuilder;
import org.grails.forge.api.RequestInfo;
import org.grails.forge.api.GrailsForgeConfiguration;
Expand All @@ -44,8 +46,12 @@
public class GitHubRedirectService {

public static final String OAUTH_URL = "micronaut.http.services." + GitHubOAuthClient.SERVICE_ID + ".url";
public static final String OAUTH_STATE_COOKIE = "GRAILS_FORGE_GITHUB_OAUTH_STATE";
private static final Logger LOG = LoggerFactory.getLogger(GitHubRedirectService.class);
private static final String AUTHORIZE_PATH = "/login/oauth/authorize";
private static final String OAUTH_STATE_COOKIE_PATH_SUFFIX = "/github";
private static final String OAUTH_STATE_SEPARATOR = "|";
private static final long OAUTH_STATE_MAX_AGE_SECONDS = 600;

private final String githubOAuthUrl;
private final GrailsForgeConfiguration starterConfiguration;
Expand All @@ -61,12 +67,134 @@ public GitHubRedirectService(
}

/**
* Creates redirect URI to github oauth auhtorise in order to receive the user access code.
* Creates a state value for GitHub OAuth callback validation.
*
* @return The OAuth state
*/
protected String createOAuthState() {
return UUID.randomUUID().toString();
}

/**
* Creates the cookie used to persist GitHub OAuth state for callback validation.
*
* @param requestInfo origin request info
* @param state The OAuth state
* @param storedStates The persisted OAuth states
* @return The OAuth state cookie
*/
protected Cookie createOAuthStateCookie(RequestInfo requestInfo, String state, String storedStates) {
return createOAuthStateCookie(requestInfo, appendOAuthState(storedStates, state));
}

private Cookie createOAuthStateCookie(RequestInfo requestInfo, String states) {
return Cookie.of(OAUTH_STATE_COOKIE, states)
.httpOnly(true)
.secure(requestInfo.getServerURL().startsWith("https://"))
.sameSite(SameSite.Lax)
.path(resolveOAuthStateCookiePath(requestInfo))
.maxAge(OAUTH_STATE_MAX_AGE_SECONDS);
}

/**
* Creates the cookie used to clear persisted GitHub OAuth state.
*
* @param requestInfo origin request info
* @return The expired OAuth state cookie
*/
protected Cookie expireOAuthStateCookie(RequestInfo requestInfo) {
return Cookie.of(OAUTH_STATE_COOKIE, "")
.httpOnly(true)
.secure(requestInfo.getServerURL().startsWith("https://"))
.sameSite(SameSite.Lax)
.path(resolveOAuthStateCookiePath(requestInfo))
.maxAge(0);
}

/**
* Creates the cookie used to remove the completed OAuth state from storage.
*
* @param requestInfo origin request info
* @param storedStates The persisted OAuth states
* @param state The completed OAuth state
* @return The updated OAuth state cookie
*/
protected Cookie consumeOAuthStateCookie(RequestInfo requestInfo, String storedStates, String state) {
String remainingStates = removeOAuthState(storedStates, state);
if (remainingStates.isEmpty()) {
return expireOAuthStateCookie(requestInfo);
}
return createOAuthStateCookie(requestInfo, remainingStates);
}

private String resolveOAuthStateCookiePath(RequestInfo requestInfo) {
String path = URI.create(requestInfo.getServerURL()).getPath();
if (path == null || path.isEmpty() || "/".equals(path)) {
return OAUTH_STATE_COOKIE_PATH_SUFFIX;
}
if (path.endsWith("/")) {
return path.substring(0, path.length() - 1) + OAUTH_STATE_COOKIE_PATH_SUFFIX;
}
return path + OAUTH_STATE_COOKIE_PATH_SUFFIX;
}

/**
* Validates that the callback state matches the persisted OAuth state.
*
* @param state The callback state
* @param storedState The persisted state
* @return Whether the state is valid
*/
protected boolean isValidOAuthState(String state, String storedState) {
return containsOAuthState(storedState, state);
}

private String appendOAuthState(String storedStates, String state) {
if (storedStates == null || storedStates.isEmpty()) {
return state;
}
if (containsOAuthState(storedStates, state)) {
return storedStates;
}
return storedStates + OAUTH_STATE_SEPARATOR + state;
}

private String removeOAuthState(String storedStates, String state) {
if (storedStates == null || storedStates.isEmpty() || state == null) {
return "";
}
StringBuilder remainingStates = new StringBuilder();
for (String storedState : storedStates.split("\\|")) {
if (!storedState.isEmpty() && !storedState.equals(state)) {
if (remainingStates.length() > 0) {
remainingStates.append(OAUTH_STATE_SEPARATOR);
}
remainingStates.append(storedState);
}
}
return remainingStates.toString();
}

private boolean containsOAuthState(String storedStates, String state) {
if (storedStates == null || storedStates.isEmpty() || state == null) {
return false;
}
for (String storedState : storedStates.split("\\|")) {
if (storedState.equals(state)) {
return true;
}
}
return false;
}

/**
* Creates redirect URI to GitHub OAuth authorize in order to receive the user access code.
*
* @param requestInfo origin request info
* @return URI to github oauth authorise
* @param state The OAuth state
* @return URI to GitHub OAuth authorize
*/
protected URI constructOAuthRedirectUrl(RequestInfo requestInfo) {
protected URI constructOAuthRedirectUrl(RequestInfo requestInfo, String state) {
try {
UriBuilder uriBuilder = UriBuilder.of(requestInfo.getServerURL()).path(requestInfo.getPath());
requestInfo.getParameters().forEachValue(uriBuilder::queryParam);
Expand All @@ -76,10 +204,10 @@ protected URI constructOAuthRedirectUrl(RequestInfo requestInfo) {
.queryParam("scope", gitHubConfiguration.getTokenPermissions())
.queryParam("client_id", gitHubConfiguration.getClientId())
.queryParam("redirect_uri", redirectUri.toString())
.queryParam("state", UUID.randomUUID().toString())
.queryParam("state", state)
.build();
} catch (Exception e) {
String msg = "Failed to construct redirect URI using request " + requestInfo + " to GiHub OAuth: " + e.getMessage();
String msg = "Failed to construct redirect URI using request " + requestInfo + " to GitHub OAuth: " + e.getMessage();
LOG.error(msg, e);
throw new RuntimeException(msg);
}
Expand Down
Loading
Loading