blog.itcode.devblog.itcode.dev

[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 7. Applying for the GitHub OAuth Service and Implementing the Module

As the last platform, let's apply for an OAuth service with GitHub and implement the authentication module.

[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 7. Applying for the GitHub OAuth Service and Implementing the Module

As the last platform, let's apply for an OAuth service with GitHub and implement the authentication module.
RWB0104
@RWBwritten at 2021-10-22 20:08:46
Building an OAuth2.0 Authorization Server

시리즈 모아보기

Building an OAuth2.0 Authorization Server

7 / 11

As the last platform, let's apply for an OAuth service with GitHub and implement the authentication module.

Let's apply for the GitHub OAuth service to obtain the API information.

After logging in, go to GitHub Developer Settings.

You can also get there by clicking [Settings - Developer Settings - OAuth Apps] in the top profile menu.

Click [New OAuth App] to create a new application.


Just fill in the required fields. It seems GitHub only allows entering a single Callback URL.

Click on the application you created. Click [Generate a new client secret] to issue a new Client Secret. A password confirmation step is required.

The key is shown right after it's created, and once you close the window you can't view that key again, so make sure to jot it down somewhere temporarily.

If you forget it, you'll need to reissue it.

You can check it on the main [General] screen.

Note that the Client ID can always be viewed, while the Client Secret can only be viewed temporarily right after it's issued.

That's all there is to GitHub OAuth. It doesn't even require a separate consent-to-provide-information step. That makes sense, since GitHub's OAuth key can only fetch profile information.

Now that all the necessary preparations are in place, let's implement the GitHub authentication module. We'll implement it by extending the previously implemented AuthModule.

JAVA

public class GithubAuthModule extends AuthModule
{
	// GitHub authentication module
}

The basic form of the object is as above.

MethodMethod TypeDescriptionImplementation Needed?
getAuthorizationUrlabstractReturns the authentication URLY
getAccessTokenReturns the access tokenY
getRefreshAccessTokenRefreshes and returns the access token
getUserInfoReturns the user info responseY
getRefreshTokenEndpointReturns the access token reissue request URL
getApiKeyBeanReturns the API key object
getUserInfoEndPointReturns the user info request URL
getUserInfoBeanabstractReturns the user info objectY
deleteInfoabstractReturns the result of unlinkingY
getUpdateAuthorizationUrlabstractReturns the URL for renewing consent to provide informationY
getAccessTokenEndpointabstractReturns the access token request URLY
getAuthorizationBaseUrlabstractReturns the authentication API request URLY

What the GitHub module needs to implement is as above. Unlike the previous platforms, getAccessToken and getUserInfo need to be overridden.

Create a github.properties file under WEB-INF. You can also copy the already-created sample.properties and use that.

PROPERTIES

api=API_KEY
secret=SECRET_KEY
callback=CALLBACK_URL

The basic format is as above; just enter the appropriate values for each item.

For the authentication module to work correctly, there are certain methods and variables that need to be set up by default, such as configuring API information and returning the instance.

JAVA

private static final String MODULE_NAME = "github";
	
private static final String API_KEY;
private static final String SECRET_KEY;
private static final String CALLBACK_URL;

static
{
	ApiKeyBean apiKeyBean = getApiKeyBean(MODULE_NAME);
	
	API_KEY = apiKeyBean.getApi();
	SECRET_KEY = apiKeyBean.getSecret();
	CALLBACK_URL = apiKeyBean.getCallback();
}

private static final ServiceBuilderOAuth20 SERVICE_BUILDER = new ServiceBuilder(API_KEY).apiSecret(SECRET_KEY).callback(CALLBACK_URL);

private static final GithubAuthModule INSTANCE = new GithubAuthModule(SERVICE_BUILDER);

private GithubAuthModule(ServiceBuilderOAuth20 serviceBuilder)
{
	super(serviceBuilder);
}

public static GithubAuthModule getInstance()
{
	return INSTANCE;
}
FieldTypeDescription
MODULE_NAMEStringThe module name
API_KEYStringThe API key
SECRET_KEYStringThe secret key
CALLBACK_URLStringThe callback URL
SERVICE_BUILDERServiceBuilderOAuth20The OAuth2.0 service builder
INSTANCEGithubAuthModuleThe instance

All the defined variables are declared as static final, so they're declared only once when the instance is created and cannot be reassigned.

Through the static{ } block, the API information is set up when the instance is created.

When assigning the API values, the getApiKeyBean() method parses the properties with the given name and returns an ApiKeyBean object, which is then used.

Let's implement the methods that return the request URL for each API.

JAVA

@Override
public String getAccessTokenEndpoint()
{
	return "https://github.com/login/oauth/access_token";
}

@Override
protected String getAuthorizationBaseUrl()
{
	return "https://github.com/login/oauth/authorize";
}

@Override
protected String getUserInfoEndPoint()
{
	return "https://api.github.com/users/RWB0104";
}
  • getAccessTokenEndpoint() - token-related APIs use the URL returned by this method.
  • getAuthorizationBaseUrl() - authentication-related APIs use the URL returned by this method.
  • getUserInfoEndPoint() - user info-related APIs use the URL returned by this method.

These are the URLs needed to carry out OAuth2.0 operations. Among these, getAccessTokenEndpoint() and getAuthorizationBaseUrl() are abstract methods of DefaultApi20, an object from the scribeJAVA library, and the remaining one is an abstract method of AuthModule.

DefaultApi20 doesn't provide a separate method related to the user account API. However, since the user account API is essential when using AuthModule's common method for fetching user info, it's managed as an abstract method of AuthModule.

Let's implement the feature that returns the GitHub platform login URL.

First, let's look at the API.


  • Request

TXT

GET https://github.com/login/oauth/authorize?response_type=code&client_id={:client_id}&redirect_uri={:redirect_uri}&state={:state}
parametertypedatarequireddescription
{:response_type}pathStringYThe response type. Fixed to code
{:client_id}pathStringYThe API key
{:redirect_uri}pathStringYThe Callback URL
{:state}pathStringYA unique state value

  • Response

The GitHub platform login page


The GitHub platform login API is as above. You just need to design the method to return the request URL.

You could build the URL directly with string operations, but you can easily generate the URL using the service.getAuthorizationUrl() method.


Since it's already declared as a common method in AuthModule, there's no need to implement it separately.

Since we receive the Code as the login result, we'll implement the feature that exchanges it for an Access Token.

GitHub requires specifying a JSON MIME type in the Accept header, but unfortunately scribeJAVA doesn't have an API that builds the authorization request with arbitrary headers attached.

Since there's no way to get the Access Token that way either, we have to implement it directly with HttpURLConnection.


  • Request

TXT

POST https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id={:client_id}&client_secret={:client_secret}&redirect_uri={:redirect_uri}&code={:code}
Accept: application/json
parametertypedatarequireddescription
{:grant_type}pathStringYThe grant type. Fixed to authorization_code
{:client_id}pathStringYThe API key
{:client_secret}pathStringYThe secret key
{:redirect_uri}pathStringYThe Callback URL
{:code}pathStringYThe authorization code

  • Response

JSON

{
	"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
	"scope": "repo,gist",
	"token_type": "bearer"
}
parameterdatadescription
access_tokenStringThe access token
token_typeStringThe token type
scopeStringThe access permissions

The request is built with an AccessTokenRequestParams object and the response is received via service.getAccessToken.

Since specifying the response header is required in this unusual case, we override it separately and use that instead.

As you can see from the response above, GitHub doesn't have a separate Refresh Token at all. There's no expiration time for the Access Token either. With GitHub, you're really just dealing with a single Access Token.

Since there's no Refresh Token, this feature itself is useless. So we don't touch it for GitHub.

Let's implement the feature that fetches user info using the Access Token. This is the part where the issued Access Token is actually put to meaningful use.

The GitHub API is as follows.


  • Request

TXT

GET/POST https://api.github.com/user
Authorization: token {:access_token}
parametertypedatarequireddescription
{:access_token}headerStringYThe access token

  • Response

JSON

{
	"login": "octocat",
	"id": 1,
	"node_id": "MDQ6VXNlcjE=",
	"avatar_url": "https://github.com/images/error/octocat_happy.gif",
	"gravatar_id": "",
	"url": "https://api.github.com/users/octocat",
	"html_url": "https://github.com/octocat",
	"followers_url": "https://api.github.com/users/octocat/followers",
	"following_url": "https://api.github.com/users/octocat/following{/other_user}",
	"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
	"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
	"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
	"organizations_url": "https://api.github.com/users/octocat/orgs",
	"repos_url": "https://api.github.com/users/octocat/repos",
	"events_url": "https://api.github.com/users/octocat/events{/privacy}",
	"received_events_url": "https://api.github.com/users/octocat/received_events",
	"type": "User",
	"site_admin": false,
	"name": "monalisa octocat",
	"company": "GitHub",
	"blog": "https://github.com/blog",
	"location": "San Francisco",
	"email": "octocat@github.com",
	"hireable": false,
	"bio": "There once was...",
	"twitter_username": "monatheoctocat",
	"public_repos": 2,
	"public_gists": 1,
	"followers": 20,
	"following": 0,
	"created_at": "2008-01-14T04:33:35Z",
	"updated_at": "2008-01-14T04:33:35Z",
	"private_gists": 81,
	"total_private_repos": 100,
	"owned_private_repos": 100,
	"disk_usage": 10000,
	"collaborators": 8,
	"two_factor_authentication": true,
	"plan": {
		"name": "Medium",
		"space": 400,
		"private_repos": 20,
		"collaborators": 0
	}
}

GitHub doesn't provide a clear response spec. What's certain is that email, login, and avatar_url should work.

Let's implement the method that parses the response according to GitHub's user info API response format and returns it as a UserInfoBean.

This project only uses the name, email, and profile picture URL, so we extract those values from the response and store them in the object.


  • Code

JAVA

@Override
public UserInfoBean getUserInfoBean(String body) throws JsonProcessingException
{
	ObjectMapper mapper = new ObjectMapper();
	
	JsonNode node = mapper.readTree(body);
	
	String email = node.get("email") == null ? "미동의" : node.get("email").textValue();
	String name = node.get("name") == null ? "미동의" : node.get("name").textValue();
	String picture = node.get("avatar_url") == null ? "/oauth2/assets/images/logo.png" : node.get("avatar_url").textValue();
	
	return new UserInfoBean(email, name, picture, MODULE_NAME);
}

We extract the needed values according to the response format. If the user didn't consent to providing the information, the target object returns null. To prevent errors from missing data, null handling must always be done for the data.

The first time you log in with a Kakao ID, you go through consent to provide information, but on subsequent logins, this consent step is skipped. In other words, this means the platform saves the consent to provide information somewhere the first time you log in. If a user withdraws their membership from the service, it's necessary to unlink from GitHub and completely delete the information.

The GitHub API is as follows.


  • Request

TXT

DELETE https://api.github.com/applications/{:client_id}/grant
Authorization: Basic {:auth}
Accept: application/vnd.github.v3+json
Content-Type: application/x-www-form-urlencoded
parametertypedatarequireddescription
{:client_id}pathStringYThe API key
{:auth}headerStringYBasic authentication combining the API key and Secret key

The ID:PW-based Basic header
The Basic header is an ID:PW-based authentication scheme. It joins the ID and PW into a single string like [ID:PW] using :. That text is used in the header.


  • Response

The response is 204, with no body.


  • Code

JAVA

@Override
public boolean deleteInfo(String access) throws IOException, ExecutionException, InterruptedException
{
	HashMap<String, String> params = new HashMap<>();
	params.put("access_token", access);
	
	ObjectMapper mapper = new ObjectMapper();
	
	byte[] paramBytes = mapper.writeValueAsString(params).getBytes(StandardCharsets.UTF_8);
	
	URL url = new URL(Util.builder("https://api.github.com/applications/", API_KEY, "/grant"));
	
	HttpURLConnection connection = (HttpURLConnection) url.openConnection();
	connection.setRequestMethod("DELETE");
	connection.addRequestProperty("Authorization", Util.builder("Basic ", Base64.getEncoder().encodeToString(Util.builder(API_KEY, ":", SECRET_KEY).getBytes())));
	connection.addRequestProperty("Accept", "application/vnd.github.v3+json");
	connection.addRequestProperty("Content-Type", "application/x-www-form-urlencoded");
	connection.setDoOutput(true);
	connection.getOutputStream().write(paramBytes);
	
	int status = connection.getResponseCode();
	
	connection.disconnect();
	
	return status == 204;
}

The implementation is simple. Using the OAuthRequest object, you can easily build the request. The response body itself doesn't matter. This time the response is 204, so we return the result by checking whether the response status equals 204.

GitHub doesn't involve any separate consent, so this is skipped.

JAVA

package oauth.account.module;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.builder.ServiceBuilderOAuth20;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthRequest;
import com.github.scribejava.core.model.Response;
import com.github.scribejava.core.model.Verb;
import global.module.Util;
import oauth.account.bean.ApiKeyBean;
import oauth.account.bean.UserInfoBean;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;

/**
 * GitHub 인증 모듈 클래스
 *
 * @author RWB
 * @since 2021.10.05 Tue 00:22:10
 */
public class GithubAuthModule extends AuthModule
{
	private static final String MODULE_NAME = "github";
	
	private static final String API_KEY;
	private static final String SECRET_KEY;
	private static final String CALLBACK_URL;
	
	static
	{
		ApiKeyBean apiKeyBean = getApiKeyBean(MODULE_NAME);
		
		API_KEY = apiKeyBean.getApi();
		SECRET_KEY = apiKeyBean.getSecret();
		CALLBACK_URL = apiKeyBean.getCallback();
	}
	
	private static final ServiceBuilderOAuth20 SERVICE_BUILDER = new ServiceBuilder(API_KEY).apiSecret(SECRET_KEY).callback(CALLBACK_URL);
	
	private static final GithubAuthModule INSTANCE = new GithubAuthModule(SERVICE_BUILDER);
	
	/**
	 * 생성자 메서드
	 *
	 * @param serviceBuilder: [ServiceBuilderOAuth20] API 서비스 빌더
	 */
	private GithubAuthModule(ServiceBuilderOAuth20 serviceBuilder)
	{
		super(serviceBuilder);
	}
	
	/**
	 * 인스턴스 반환 메서드
	 *
	 * @return [GithubAuthModule] 인스턴스
	 */
	public static GithubAuthModule getInstance()
	{
		return INSTANCE;
	}
	
	/**
	 * 접근 토큰 반환 메서드
	 *
	 * @param code: [String] 인증 코드
	 *
	 * @return [OAuth2AccessToken] 접근 토큰
	 *
	 * @throws IOException 데이터 입출력 예외
	 */
	@Override
	public OAuth2AccessToken getAccessToken(String code) throws IOException
	{
		HashMap<String, String> params = new HashMap<>();
		params.put("client_id", API_KEY);
		params.put("client_secret", SECRET_KEY);
		params.put("redirect_uri", CALLBACK_URL);
		params.put("code", code);
		
		StringBuilder builder = new StringBuilder();
		
		for (Map.Entry<String, String> param : params.entrySet())
		{
			String pre = builder.length() == 0 ? "" : "&";
			
			builder.append(pre).append(URLEncoder.encode(param.getKey(), StandardCharsets.UTF_8)).append("=").append(URLEncoder.encode(param.getValue(), StandardCharsets.UTF_8));
		}
		
		byte[] paramBytes = builder.toString().getBytes(StandardCharsets.UTF_8);
		
		URL url = new URL(getAccessTokenEndpoint());
		
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod("POST");
		connection.setRequestProperty("Accept", "application/json");
		connection.setDoOutput(true);
		connection.getOutputStream().write(paramBytes);
		
		BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
		
		StringBuilder responseBuilder = new StringBuilder();
		String temp;
		
		while ((temp = reader.readLine()) != null)
		{
			responseBuilder.append(temp);
		}
		
		reader.close();
		connection.disconnect();
		
		ObjectMapper mapper = new ObjectMapper();
		
		JsonNode node = mapper.readTree(responseBuilder.toString());
		
		String access_token = node.get("access_token") == null ? "미동의" : node.get("access_token").textValue();
		String token_type = node.get("token_type") == null ? "미동의" : node.get("token_type").textValue();
		String scope = node.get("scope") == null ? "미동의" : node.get("scope").textValue();
		
		return new OAuth2AccessToken(access_token, token_type, 0, null, scope, responseBuilder.toString());
	}
	
	/**
	 * 사용자 정보 응답 반환 메서드
	 *
	 * @param access: [String] 접근 토큰
	 *
	 * @return [Response] 사용자 정보 응답
	 *
	 * @throws IOException 데이터 입출력 예외
	 * @throws ExecutionException 실행 예외
	 * @throws InterruptedException 인터럽트 예외
	 */
	@Override
	public Response getUserInfo(String access) throws IOException, ExecutionException, InterruptedException
	{
		OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, getUserInfoEndPoint());
		oAuthRequest.addHeader("Authorization", Util.builder("token ", access));
		
		service.signRequest(access, oAuthRequest);
		
		return service.execute(oAuthRequest);
	}
	
	/**
	 * 유저 정보 객체 반환 메서드
	 *
	 * @param body: [String] OAuth 응답 내용
	 *
	 * @return [UserInfoBean] 유저 정보 객체
	 *
	 * @throws JsonProcessingException JSON 파싱 예외
	 */
	@Override
	public UserInfoBean getUserInfoBean(String body) throws JsonProcessingException
	{
		ObjectMapper mapper = new ObjectMapper();
		
		JsonNode node = mapper.readTree(body);
		
		String email = node.get("email") == null ? "미동의" : node.get("email").textValue();
		String name = node.get("name") == null ? "미동의" : node.get("name").textValue();
		String picture = node.get("avatar_url") == null ? "/oauth2/assets/images/logo.png" : node.get("avatar_url").textValue();
		
		return new UserInfoBean(email, name, picture, MODULE_NAME);
	}
	
	/**
	 * 연동 해제 결과 반환 메서드
	 *
	 * @param access: [String] 접근 토큰
	 *
	 * @return [boolean] 연동 해제 결과
	 *
	 * @throws IOException 데이터 입출력 예외
	 */
	@Override
	public boolean deleteInfo(String access) throws IOException
	{
		HashMap<String, String> params = new HashMap<>();
		params.put("access_token", access);
		
		ObjectMapper mapper = new ObjectMapper();
		
		byte[] paramBytes = mapper.writeValueAsString(params).getBytes(StandardCharsets.UTF_8);
		
		URL url = new URL(Util.builder("https://api.github.com/applications/", API_KEY, "/grant"));
		
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod("DELETE");
		connection.addRequestProperty("Authorization", Util.builder("Basic ", Base64.getEncoder().encodeToString(Util.builder(API_KEY, ":", SECRET_KEY).getBytes())));
		connection.addRequestProperty("Accept", "application/vnd.github.v3+json");
		connection.addRequestProperty("Content-Type", "application/x-www-form-urlencoded");
		connection.setDoOutput(true);
		connection.getOutputStream().write(paramBytes);
		
		int status = connection.getResponseCode();
		
		connection.disconnect();
		
		return status == 204;
	}
	
	/**
	 * 정보 제공 동의 갱신 URL 반환 메서드
	 *
	 * @param state: [String] 고유 상태값
	 *
	 * @return [String] 정보 제공 동의 갱신 URL
	 */
	@Override
	public String getUpdateAuthorizationUrl(String state)
	{
		return null;
	}
	
	/**
	 * 접근 토큰 요청 URL 반환 메서드
	 *
	 * @return [String] 접근 토큰 요청 URL
	 */
	@Override
	public String getAccessTokenEndpoint()
	{
		return "https://github.com/login/oauth/access_token";
	}
	
	/**
	 * 인증 API 요청 URL 반환 메서드
	 *
	 * @return [String] 인증 API 요청 URL
	 */
	@Override
	protected String getAuthorizationBaseUrl()
	{
		return "https://github.com/login/oauth/authorize";
	}
	
	/**
	 * 사용자 정보 요청 URL 반환 메서드
	 *
	 * @return [String] 사용자 정보 요청 URL
	 */
	@Override
	protected String getUserInfoEndPoint()
	{
		return "https://api.github.com/user";
	}
}

The full, organized code is as above.

With this, the implementation of the authentication modules for all platforms is complete. By leveraging AuthModule, I was able to implement modules for each platform with a minimal amount of code. If I need to add another OAuth provider in the future, I can add a module in the same way as above.

The next chapter will cover implementing the processes, the layer that calls and uses these modules.

# JAVA# OAuth2.0# scribeJAVA
ship
blog.itcode.dev

Notes from the π-th Alpaca

7.0.1
Developed by RWB since 2021.057th upgraded at 2026.08