blog.itcode.devblog.itcode.dev

[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 3. Implementing the OAuth2.0 Authentication Module with scribeJAVA

Let's implement an authentication module using the OAuth library scribeJAVA.

[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 3. Implementing the OAuth2.0 Authentication Module with scribeJAVA

Let's implement an authentication module using the OAuth library scribeJAVA.
RWB0104
@RWBwritten at 2021-10-19 16:26:40
Building an OAuth2.0 Authorization Server

시리즈 모아보기

Building an OAuth2.0 Authorization Server

3 / 11

Let's implement an authentication module using the OAuth library scribeJAVA.

As mentioned in the previous chapter, because of its shared characteristics, an abstract object is well suited for the OAuth authentication module.

We'll implement the abstract object using the scribeJAVA module.

Let's apply scribeJAVA to the project.

GROOVY

implementation group: 'com.github.scribejava', name: 'scribejava-apis', version: '8.3.1'

You can apply scribeJAVA by adding the above dependency to the dependencies section of build.gradle.

Let's dive into using scribeJAVA in earnest.

scribeJAVA operates around an object called OAuth20Service. Using the OAuth20Service object, you can perform the logic below.

  • Generating the platform login URL
  • Exchanging an authorization code for an Access Token and Refresh Token
  • Refreshing the Access Token using the Refresh Token
  • Creating other OAuth-related requests

In other words, all core operations for OAuth authentication revolve around this OAuth20Service object.

Let's create the OAuth20Service object. The elements required are as follows.

FieldRequiredDescription
API KeyYThe API key
API Secret KeyYThe API secret key
Callback URLYThe URL to which the login result will be returned
ScopeN (Y for some platforms)The requested permissions

The API Key, API Secret Key, and Callback URL are always required by default, and for some platforms, Scope is also specified as a required item. Among the platforms applied in this project, Google is one such case. Google throws an error during platform login URL generation (covered below) if Scope isn't specified.

The API Key and API Secret Key are issued by each platform when you register for their OAuth service, and you can define and enter the Callback URL yourself. If you attempt to log in with a Callback URL that hasn't been registered, an error is returned. You can register multiple Callback URLs.

Why the Callback URL must be registered
The reason an error appears when the Callback URL isn't a pre-registered URL is security. In the platform login URL, the Callback URL is embedded as a URL parameter, which makes it very easy to intercept or tamper with. In this situation, if proper validation of the Callback URL isn't performed, an attacker could change the Callback URL to an arbitrary URL and steal the code or the Access Token.

How to register an OAuth service for each platform will be covered later; for now, let's assume these preliminary steps are already in place.

JAVA

OAuth20Service service = new ServiceBuilder("{API_KEY}").apiSecret("{SECRET_KEY}").callback("{CALLBACK_URL}").build(this);

It can be created like this. In build(this), this refers to a DefaultApi20 object. Below are the methods of the OAuth20Service object and their functions.

MethodDescription
getAuthorizationUrlReturns the platform login URL
getAccessTokenReturns an OAuth2AccessToken
signRequestRegisters an OAuth request
executePerforms the registered request

For this project, it's enough to know just these 4 uses.

Now let's actually implement the module object. The authentication module must be implemented by extending DefaultApi20.

JAVA

abstract public class AuthModule extends DefaultApi20
{
	// To be implemented
}

The DefaultApi20 abstract object has two abstract methods. That means AuthModule, which extends it, is responsible for implementing these two methods.

  • getAccessTokenEndpoint - returns the URL for the access token request
  • getAuthorizationBaseUrl - returns the authorization URL

However, since AuthModule is also an abstract object, it can delegate this responsibility to the platform-specific authentication modules that extend it. In other words, DefaultApi20's abstract methods will not be implemented in AuthModule, but rather in the platform-specific authentication modules that extend it.

The authentication module abstract object takes the form above. Per-platform authentication modules for NAVER, Google, etc. will extend this AuthModule. Since the core operations of the authentication module mostly come from the OAuth20Service object, it seems sensible to explicitly require the related object to be passed in when extending AuthModule.

JAVA

abstract public class AuthModule extends DefaultApi20
{
    protected OAuth20Service service;
	
	@Getter
	protected String unique;

    protected AuthModule(ServiceBuilderOAuth20 serviceBuilder, String unique)
	{
		service = serviceBuilder.build(this);
		
		this.unique = unique;
	}
}
FieldData TypeDescription
serviceBuilderServiceBuilderOAuth20Builder for the OAuth20Service object
uniqueStringThe authentication module's unique value. Matches the platform's lowercase name (e.g. NAVER -> naver)

The constructor and member variables are declared as above. Since they all use the protected access modifier, the constructor can only be used by objects that extend AuthModule, and the same applies to the service and unique parameters.

ServiceBuilderOAuth20 is received as an argument, built into an OAuth20Service in the constructor, and assigned to the member variable service. service and unique can be accessed from anywhere in an object that extends AuthModule.

Basic logic is written in this module, and where the implementation differs by platform, an abstract method is declared to delegate the actual implementation to the object that extends it.

Let's implement the method that returns the authentication URL using scribeJAVA. For example, when you click the "Log in with Naver ID" button, the Naver login popup will appear. This process is about generating the URL for a platform login window, as in the example above.

This can be simply implemented using the service's getAuthorizationUrl method. It generates and returns the platform authentication URL based on the API Key, Secret Key, and Callback URL that were passed to ServiceBuilderOAuth20.

JAVA

public String getAuthorizationUrl(String state)
{
	return service.getAuthorizationUrl(state);
}

It's generated based on the URL returned by getAuthorizationBaseUrl.

The argument state is a unique state value, generated on the server as an arbitrary UUID. This value is used as a session check for security purposes.

Now let's implement a method that returns an OAuth2AccessToken access token object. OAuth2AccessToken is a scribeJAVA object that holds the Access Token, Refresh Token, token type, and validity period.

JAVA

public OAuth2AccessToken getAccessToken(String code) throws IOException, ExecutionException, InterruptedException
{
    return service.getAccessToken(code);
}

This too can be simply implemented using the service's getAccessToken method. When the argument code is sent to the Service Provider, it provides the Access Token and Refresh Token.

For security, an Access Token typically has a very short expiration time, or expires along with the session. In this case, the user would normally need to be re-authenticated, and depending on the situation, might even be asked to log in to the platform again.

Most OAuth platforms provide a Refresh Token together with the Access Token at the time of authentication.

TypeExpirationCan Authenticate?Description
Access TokenShort or temporaryYesThe token responsible for authentication. The mere presence of an Access Token is assumed to mean the user has provided authentication information.
Refresh TokenLong or permanentNoA token used to reissue the Access Token. By itself, the Refresh Token can't be used for anything meaningful other than reissuing.

The difference between the two tokens is as above.

Let's reissue the Access Token from the Refresh Token in scribeJAVA.

JAVA

public OAuth2AccessToken getRefreshAccessToken(String refresh) throws IOException
{
	HashMap<String, String> params = new HashMap<>();
	params.put("client_id", service.getApiKey());
	params.put("client_secret", service.getApiSecret());
	params.put("refresh_token", refresh);
	
	StringBuilder builder = new StringBuilder();
	
	for (Map.Entry<String, String> param : params.entrySet())
	{
		builder.append("&").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(getRefreshTokenEndpoint());
	
	HttpURLConnection connection = (HttpURLConnection) url.openConnection();
	connection.setRequestMethod("POST");
	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();
	
	ObjectMapper mapper = new ObjectMapper();
	
	JsonNode node = mapper.readTree(responseBuilder.toString());
	
	String access_token = node.get("access_token").textValue();
	String token_type = node.get("token_type").textValue();
	int expires_in = node.get("expires_in").intValue();
	
	return new OAuth2AccessToken(access_token, token_type, expires_in, null, null, responseBuilder.toString());
}

@Override
public String getRefreshTokenEndpoint()
{
	return Util.builder(getAccessTokenEndpoint(), "?grant_type=refresh_token");
}

The code for reissuing the Access Token is as above. There's actually a service.refreshAccessToken() method, but it seems like it doesn't work properly — possibly because getRefreshTokenEndpoint handling differs by platform. I just designed the request directly using HttpURLConnection in accordance with the OAuth2.0 spec.

The getRefreshTokenEndpoint method returns the base URL used when performing Refresh Token-related operations. It returns the getAccessTokenEndpoint URL with the ?grant_type=refresh_token parameter appended.

Using the URL returned by getRefreshTokenEndpoint as the base, we send client_id, client_secret, and refresh_token as parameters, receive the response, extract what's needed from it, and build and return an OAuth2AccessToken object.

If the Access Token was successfully obtained, you can use it to fetch the user's information.

JAVA

public Response getUserInfo(String access) throws IOException, ExecutionException, InterruptedException
{
	OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, getUserInfoEndPoint());
	service.signRequest(access, oAuthRequest);
	
	return service.execute(oAuthRequest);
}

abstract protected String getUserInfoEndPoint();
	
abstract public UserInfoBean getUserInfoBean(String body) throws JsonProcessingException;

A total of three methods need to be defined — one is directly implemented, and the other two are abstract methods.

The getUserInfo method uses the Access Token to send a request to the platform's UserInfo API and returns the response.

An OAuthRequest object is declared to specify the request method and target URL. Then, the request is registered with service via service.signRequest, and executed with service.execute.

The getUserInfoEndPoint method is an abstract method that returns the UserInfo API URL for each platform. Since the URL differs by platform, it's declared as an abstract method, delegating the implementation responsibility to the platform-specific authentication modules.

getUserInfoBean is an abstract method that takes the response from getUserInfo, converts it into a UserInfoBean DTO, and returns it. Note that UserInfoBean isn't included in scribeJAVA — it's a DTO object I wrote myself.

Since the user info response differs by platform, this method is designed to handle that appropriately and return the value. This too is delegated to the platform-specific authentication modules for implementation.

We need a method to fully unlink from the platform.

This isn't simply logging out — it completely severs the connection between the platform and that user. In this process, the user's related data and their history of consent to provide information are also destroyed.

If the user logs in again after unlinking, they'll have to select the terms and consent to provide information again, just as they did the first time they logged in.

JAVA

abstract public boolean deleteInfo(String access) throws IOException, ExecutionException, InterruptedException;

Since unlinking falls slightly outside the scope of OAuth, it likewise doesn't have a common interface. Because the implementation varies widely from platform to platform, it's defined as an abstract method.

While running a service, the information you need to request from users can sometimes change.

What if, after running smoothly for a while, you suddenly need additional information from users? Even if you call for their information, there's no consent record for it, so you can't obtain it at all.

To prepare for this situation, we need a feature to renew consent to provide information.

JAVA

abstract public String getUpdateAuthorizationUrl(String state);

Renewing consent to provide information also seems to differ slightly from platform to platform. Let's likewise define it as an abstract method.

Lastly, although it's not directly related to scribeJAVA, we need a method to load the API-related elements.

The three elements needed to use OAuth2.0 are the API Key, API Secret Key, and Callback URL. Hardcoding these directly into the code isn't a great approach.

We'll manage the per-platform API elements as .properties files, kept under WEB-INF/.

The special nature of WEB-INF
Tomcat's WEB-INF is a bit special. Normally, all folders and files under the deployment path are accessible via the web, but folders and files located under WEB-INF are excluded from the deployment target and can't be accessed. However, they still exist on the file system, so access from the Backend, such as JAVA, which isn't bound by this restriction, is unaffected.
Files that require particular security — such as API keys and encryption keys, which are needed to run the web server but demand extra care — are best managed under WEB-INF.

By managing files this way and excluding each platform's configuration file via gitignore, those files get excluded even when uploading to GitHub. So even with the code open-sourced this way, you can prevent your API credentials from leaking.

The code is as follows.

JAVA

protected static ApiKeyBean getApiKeyBean(String platform)
{
	ApiKeyBean apiKeyBean;
	apiKeyBean = new ApiKeyBean();
	
	// API 키 획득 시도
	try
	{
		HashMap<String, String> map = Util.getProperties(platform);
		
		apiKeyBean.setApi(map.get("api"));
		apiKeyBean.setSecret(map.get("secret"));
		apiKeyBean.setCallback(map.get("callback"));
	}
	
	// 예외
	catch (Exception e)
	{
		e.printStackTrace();
	}
	
	return apiKeyBean;
}

JAVA

@Getter
@Setter
public class ApiKeyBean
{
	// API 키
	private String api;
	
	// API SECRET 키
	private String secret;
	
	// 콜백 URL
	private String callback;
}

ApiKeyBean is an object I designed myself, as shown in the code above. It has lombok applied.

JAVA has a feature that reads a .properties file and converts it into a key-value HashMap.

PROPERTIES

api={API_KEY}
secret={SECRET_KEY}
callback={CALLBACK_URL}

The configuration file looks like the above. Using this method, we configure it to load the API configuration file for each platform.

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.ServiceBuilderOAuth20;
import com.github.scribejava.core.builder.api.DefaultApi20;
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 com.github.scribejava.core.oauth.AccessTokenRequestParams;
import com.github.scribejava.core.oauth.OAuth20Service;
import global.module.Util;
import lombok.Getter;
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.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;

/**
 * 인증 모듈 추상 클래스
 *
 * @author RWB
 * @since 2021.09.29 Wed 23:30:47
 */
abstract public class AuthModule extends DefaultApi20
{
	protected OAuth20Service service;
	
	@Getter
	protected String unique;
	
	/**
	 * 생성자 메서드
	 *
	 * @param serviceBuilder: [ServiceBuilderOAuth20] API 서비스 빌더
	 * @param unique: [String] 유니크 키
	 */
	protected AuthModule(ServiceBuilderOAuth20 serviceBuilder, String unique)
	{
		service = serviceBuilder.build(this);
		
		this.unique = unique;
	}
	
	abstract protected String getUserInfoEndPoint();
	
	abstract public UserInfoBean getUserInfoBean(String body) throws JsonProcessingException;
	
	abstract public boolean deleteInfo(String access) throws IOException, ExecutionException, InterruptedException;
	
	abstract public String getUpdateAuthorizationUrl(String state);
	
	/**
	 * 인증 URL 반환 메서드
	 *
	 * @param state: [String] 고유 상태값
	 *
	 * @return [String] 인증 URL
	 */
	public String getAuthorizationUrl(String state)
	{
		return service.getAuthorizationUrl(state);
	}
	
	/**
	 * 접근 토큰 반환 메서드
	 *
	 * @param code: [String] 인증 코드
	 *
	 * @return [OAuth2AccessToken] 접근 토큰
	 *
	 * @throws IOException 데이터 입출력 예외
	 * @throws ExecutionException 실행 예외
	 * @throws InterruptedException 인터럽트 예외
	 */
	public OAuth2AccessToken getAccessToken(String code) throws IOException, ExecutionException, InterruptedException
	{
		return service.getAccessToken(code);
	}
	
	/**
	 * 접근 토큰 반환 메서드
	 *
	 * @param params: [AccessTokenRequestParams] AccessTokenRequestParams 객체
	 *
	 * @return [OAuth2AccessToken] 접근 토큰
	 *
	 * @throws IOException 데이터 입출력 예외
	 * @throws ExecutionException 실행 예외
	 * @throws InterruptedException 인터럽트 예외
	 */
	public OAuth2AccessToken getAccessToken(AccessTokenRequestParams params) throws IOException, ExecutionException, InterruptedException
	{
		return service.getAccessToken(params);
	}
	
	/**
	 * 접근 토큰 갱신 및 반환 메서드
	 *
	 * @param refresh: [String] 리프레쉬 토큰
	 *
	 * @return [OAuth2AccessToken] 접근 토큰
	 *
	 * @throws IOException 데이터 입출력 예외
	 */
	public OAuth2AccessToken getRefreshAccessToken(String refresh) throws IOException
	{
		HashMap<String, String> params = new HashMap<>();
		params.put("client_id", service.getApiKey());
		params.put("client_secret", service.getApiSecret());
		params.put("refresh_token", refresh);
		
		StringBuilder builder = new StringBuilder();
		
		for (Map.Entry<String, String> param : params.entrySet())
		{
			builder.append("&").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(getRefreshTokenEndpoint());
		
		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
		connection.setRequestMethod("POST");
		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();
		
		ObjectMapper mapper = new ObjectMapper();
		
		JsonNode node = mapper.readTree(responseBuilder.toString());
		
		String access_token = node.get("access_token").textValue();
		String token_type = node.get("token_type").textValue();
		int expires_in = node.get("expires_in").intValue();
		
		return new OAuth2AccessToken(access_token, token_type, expires_in, refresh, null, responseBuilder.toString());
	}
	
	/**
	 * 사용자 정보 응답 반환 메서드
	 *
	 * @param access: [String] 접근 토큰
	 *
	 * @return [Response] 사용자 정보 응답
	 *
	 * @throws IOException 데이터 입출력 예외
	 * @throws ExecutionException 실행 예외
	 * @throws InterruptedException 인터럽트 예외
	 */
	public Response getUserInfo(String access) throws IOException, ExecutionException, InterruptedException
	{
		OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, getUserInfoEndPoint());
		service.signRequest(access, oAuthRequest);
		
		return service.execute(oAuthRequest);
	}
	
	/**
	 * 접근 토큰 재발급 요청 URL 반환 메서드
	 *
	 * @return [String] 접근 토큰 재발급 요청 URL
	 */
	@Override
	public String getRefreshTokenEndpoint()
	{
		return Util.builder(getAccessTokenEndpoint(), "?grant_type=refresh_token");
	}
	
	/**
	 * API 키 객체 반환 메서드
	 *
	 * @param platform: [String] 플랫폼
	 *
	 * @return [ApiKeyBean] API 키 객체
	 */
	protected static ApiKeyBean getApiKeyBean(String platform)
	{
		ApiKeyBean apiKeyBean;
		apiKeyBean = new ApiKeyBean();
		
		// API 키 획득 시도
		try
		{
			HashMap<String, String> map = Util.getProperties(platform);
			
			apiKeyBean.setApi(map.get("api"));
			apiKeyBean.setSecret(map.get("secret"));
			apiKeyBean.setCallback(map.get("callback"));
		}
		
		// 예외
		catch (Exception e)
		{
			e.printStackTrace();
		}
		
		return apiKeyBean;
	}
}

The full source is as above.

The Util object used here and there is a common module that gathers general-purpose methods used throughout this project.

Using scribeJAVA, we implemented the abstract object that serves as the prototype for the authentication module. Based on this module, we'll be able to extend and develop authentication modules for each individual platform.

The next chapter dives into applying for OAuth services for each platform and implementing the authentication modules.

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

Notes from the π-th Alpaca

7.0.1
Developed by RWB since 2021.057th upgraded at 2026.08