[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 5. Applying for the Google OAuth Service and Implementing the Module
[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 5. Applying for the Google OAuth Service and Implementing the Module
As the second platform, let's apply for an OAuth service with Google and implement the authentication module.
Let's apply for the Google OAuth service to obtain the API information.
After logging in, go to Google Cloud Platform.
You can apply for the OAuth service in Google Cloud Platform (GCP).
Click [APIs & Services] in the left sidebar to access the related menu.
Let's create a project that will manage the OAuth information. The name doesn't matter — pick anything you like.
To create an OAuth API, you first need to configure the consent screen. Yes, that's the consent to provide information.
You can configure it in the [OAuth Consent Screen] menu in the left sidebar.
- Internal - can only be used by a specified group. Since it's closed, the app review process can be skipped.
- External - can be used by all users. Since it's open, the app review process is required.
Select the type you want. For this project, select [External].
Fill in the information about the application.
Fill in the required information and move on.
Specify the scope that the Access Token will have.
Since this project only plans to use profile information, select /auth/userinfo.email and /auth/userinfo.profile.
These two pieces of information are very basic, so they're shown as [Non-sensitive scopes]. You can add other scopes too, but keep in mind that selecting [Sensitive scopes] or [Restricted scopes] may require you to submit additional materials during app review.
Register the accounts that can use this OAuth API during the development stage. Since the application owner is an admin, there's no need to register them separately. If multiple accounts need to use it, for reasons such as collaboration, register them here.
You can review the information you've entered. This content can be modified at any time later through the same menu, following the same process.
Go to the [Credentials] menu in the left sidebar.
Select [OAuth Client ID] to create an API key.
Select the type of application. Here, select [Web application].
Enter whatever name you like.
- Authorized JavaScript Origins - only for Implicit Grant. If you're calling the API directly from JavaScript with the Google API SDK, enter the URL from which the call is made.
- Authorized Redirect URIs - only for Authorization Code Grant. Enter the URI to redirect to after authentication.
Since this project uses the Authorization Code Grant, select [Authorized Redirect URIs] and specify the URI to redirect to.
When calling up the platform login window, an error is shown if the URI isn't registered, so be sure to enter it accurately.
Once you finish saving, the API key is created.
You can click on the created API list to check the API.
You can renew the client secret via [Reset Secret] at the top.
Now that all the necessary preparations are in place, let's implement the Google authentication module. We'll implement it by extending the previously implemented AuthModule.
JAVA
public class GoogleAuthModule extends AuthModule { // Google authentication module }
The basic form of the object is as above.
| Method | Method Type | Description | Implementation Needed? |
|---|---|---|---|
| getAuthorizationUrl | abstract | Returns the authentication URL | Y |
| getAccessToken | Returns the access token | ||
| getRefreshAccessToken | Refreshes and returns the access token | ||
| getUserInfo | Returns the user info response | ||
| getRefreshTokenEndpoint | Returns the access token reissue request URL | ||
| getApiKeyBean | Returns the API key object | ||
| getUserInfoEndPoint | Returns the user info request URL | ||
| getUserInfoBean | abstract | Returns the user info object | Y |
| deleteInfo | abstract | Returns the result of unlinking | Y |
| getUpdateAuthorizationUrl | abstract | Returns the URL for renewing consent to provide information | Y |
| getAccessTokenEndpoint | abstract | Returns the access token request URL | Y |
| getAuthorizationBaseUrl | abstract | Returns the authentication API request URL | Y |
What the Google module needs to implement is as above, the same as Naver.
Create a google.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 = "google"; 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).defaultScope("https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"); private static final GoogleAuthModule INSTANCE = new GoogleAuthModule(SERVICE_BUILDER); private GoogleAuthModule(ServiceBuilderOAuth20 serviceBuilder) { super(serviceBuilder); } public static GoogleAuthModule getInstance() { return INSTANCE; }
For Google, scope must always be specified. Just plug in the scope you selected in 3-3. Specifying Scopes as the scope used here.
| Field | Type | Description |
|---|---|---|
| MODULE_NAME | String | The module name |
| API_KEY | String | The API key |
| SECRET_KEY | String | The secret key |
| CALLBACK_URL | String | The callback URL |
| SERVICE_BUILDER | ServiceBuilderOAuth20 | The OAuth2.0 service builder |
| INSTANCE | GoogleAuthModule | The 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://oauth2.googleapis.com/token"; } @Override protected String getAuthorizationBaseUrl() { return "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent"; } @Override protected String getUserInfoEndPoint() { return "https://www.googleapis.com/oauth2/v2/userinfo"; }
- 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 Google platform login URL.
First, let's look at the API.
- Request
TXT
GET/POST https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent&response_type=code&client_id=${:client_id}&redirect_uri={:redirect_uri}&scope={:scope}&state={:state}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:response_type} | path | String | Y | The response type. Fixed to code |
| {:client_id} | path | String | Y | The API key |
| {:redirect_uri} | path | String | Y | The Callback URL |
| {:state} | path | String | Y | A unique state value |
| {:scope} | path | String | The access scope. Enter the selected scopes separated by spaces | |
| {:access_type} | path | String | Whether this is a browser environment. Fixed to offline | |
| {:prompt} | path | String | Prompt mode. Fixed to consent |
- Response
The Google platform login page
The Google 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.
There's one difference here that's unique to Google compared to other platforms — the presence of access_type and prompt.
If you log in via Google using a normal URL, it only provides a Refresh Token on the very first login. In other words, you have to save the Refresh Token somewhere on that first login.
Since this information must not be lost, it's appropriate to store it in a DB rather than a cookie or local storage. However, this project doesn't store user information separately.
In this case, appending the above parameters to the login URL forces re-authentication every time, so a Refresh Token is provided on every login.
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.
The Google API is as follows.
- Request
TXT
POST https://oauth2.googleapis.com/token?grant_type=authorization_code&client_id={:client_id}&client_secret={:client_secret}&code={:code}&state={:state}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:grant_type} | path | String | Y | The grant type. Fixed to authorization_code |
| {:client_id} | path | String | Y | The API key |
| {:client_secret} | path | String | Y | The secret key |
| {:code} | path | String | Y | The authorization code |
| {:state} | path | String | A unique state value |
- Response
JSON
{ "access_token": "1/fFAGRNJru1FTz70BzhT3Zg", "expires_in": 3920, "token_type": "Bearer", "scope": "https://www.googleapis.com/auth/drive.metadata.readonly", "refresh_token": "1//xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI" }
| parameter | data | description |
|---|---|---|
| access_token | String | The access token |
| refresh_token | String | The refresh token |
| token_type | String | The token type |
| expires_in | int | The expiration time (in seconds) |
| scope | String | The access permissions |
The response of the service.getAccessToken() method gives us the OAuth2AccessToken object, which is the DTO for the JSON response above.
Since this too can be handled by the common method declared in AuthModule, there's no need to implement it separately.
The Access Token has a very short expiration time, about one hour. When the Access Token expires, normally the user would need to be asked to re-authenticate via platform login, but if a Refresh Token is available, you can reissue the Access Token internally in the service without going through any extra steps.
This Refresh Token doesn't carry authentication rights, but it does carry the right to reissue an Access Token.
The Google API implementing this is as follows.
- Request
TXT
POST https://oauth2.googleapis.com/token?grant_type=refresh_token&client_id={:client_id}&client_secret={:client_secret}&refresh_token=${:refresh_token}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:grant_type} | path | String | Y | The grant type. Fixed to refresh_token |
| {:client_id} | path | String | Y | The API key |
| {:client_secret} | path | String | Y | The secret key |
| {:refresh_token} | path | String | Y | The refresh token |
- Response
JSON
{ "access_token": "1/fFAGRNJru1FTz70BzhT3Zg", "expires_in": 3920, "scope": "https://www.googleapis.com/auth/drive.metadata.readonly", "token_type": "Bearer" }
| parameter | data | description |
|---|---|---|
| access_token | String | The access token |
| token_type | String | The token type |
| expires_in | String | The expiration time (in seconds) |
| scope | String | The access permissions |
Since this can be replaced by AuthModule's common method, it's not implemented separately.
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 Google API is as follows.
- Request
TXT
GET https://www.googleapis.com/oauth2/v3/userinfo Authorization: Bearer {:access_token}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:access_token} | header | String | Y | The access token |
- Response
JSON
{ "sub": "90234582532742", "name": "First Last", "given_name": "First", "family_name": "Last", "picture": "https://lh3.googleusercontent.com/a-/hash", "email": "example@gmail.com", "email_verified": true, "locale": "ko" }
| parameter | data | description |
|---|---|---|
| sub | String | A unique identifier hash for the same person |
| name | String | Full name |
| given_name | String | Given name |
| family_name | String | Family name |
| picture | String | Profile picture URL |
| String | The user's email address | |
| email_verified | boolean | Whether the email is verified |
| locale | String | Locale |
I couldn't find clearly provided docs for the Google Profile response, so this is written based on the response I got by requesting my own Access Token. In addition to the table above, more data may be sent depending on the scope, so keep that in mind.
The id is not the xxx@google.com-style ID we normally think of, but a unique hash value assigned per account.
Since this can be replaced by AuthModule's common method, it's not implemented separately.
Let's implement the method that parses the response according to Google'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("picture") == null ? "/oauth2/assets/images/logo.png" : node.get("picture").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 Google 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 Google and completely delete the information.
The Google API is as follows.
- Request
TXT
POST https://oauth2.googleapis.com/revoke?token={:token}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:token} | path | String | Y | The access token |
- Response
JSON
{}
- Code
JAVA
@Override public boolean deleteInfo(String access) throws IOException, ExecutionException, InterruptedException { OAuthRequest oAuthRequest = new OAuthRequest(Verb.POST, "https://oauth2.googleapis.com/revoke"); oAuthRequest.addBodyParameter("token", access); service.signRequest(access, oAuthRequest); return service.execute(oAuthRequest).isSuccessful(); }
The implementation is simple. Using the OAuthRequest object, you can easily build the request. Since the response body itself doesn't matter, and for Google a response code of 200 is equivalent to a 204 with no content at all, we just use response.isSuccessful() to determine whether the response is normal and return that as a boolean.
For Google, profile information doesn't require separate consent. So this feature is excluded from the implementation.
- Code
JAVA
@Override public String getUpdateAuthorizationUrl(String state) { return null; }
We return null so that no action is performed. The process that follows handles the case where a null value is returned separately.
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.OAuthRequest; import com.github.scribejava.core.model.Verb; import oauth.account.bean.ApiKeyBean; import oauth.account.bean.UserInfoBean; import java.io.IOException; import java.util.concurrent.ExecutionException; /** * Google 인증 모듈 클래스 * * @author RWB * @since 2021.09.29 Wed 23:45:27 */ public class GoogleAuthModule extends AuthModule { private static final String MODULE_NAME = "google"; 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).defaultScope("https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"); private static final GoogleAuthModule INSTANCE = new GoogleAuthModule(SERVICE_BUILDER); /** * 생성자 메서드 * * @param serviceBuilder: [ServiceBuilderOAuth20] API 서비스 빌더 */ private GoogleAuthModule(ServiceBuilderOAuth20 serviceBuilder) { super(serviceBuilder); } /** * 인스턴스 반환 메서드 * * @return [GoogleAuthModule] 인스턴스 */ public static GoogleAuthModule getInstance() { return INSTANCE; } /** * 유저 정보 객체 반환 메서드 * * @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("picture") == null ? "/oauth2/assets/images/logo.png" : node.get("picture").textValue(); return new UserInfoBean(email, name, picture, MODULE_NAME); } /** * 연동 해제 결과 반환 메서드 * * @param access: [String] 접근 토큰 * * @return [boolean] 연동 해제 결과 * * @throws IOException 데이터 입출력 예외 * @throws ExecutionException 실행 예외 * @throws InterruptedException 인터럽트 예외 */ @Override public boolean deleteInfo(String access) throws IOException, ExecutionException, InterruptedException { OAuthRequest oAuthRequest = new OAuthRequest(Verb.POST, "https://oauth2.googleapis.com/revoke"); oAuthRequest.addBodyParameter("token", access); service.signRequest(access, oAuthRequest); return service.execute(oAuthRequest).isSuccessful(); } /** * 정보 제공 동의 갱신 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://oauth2.googleapis.com/token"; } /** * 인증 API 요청 URL 반환 메서드 * * @return [String] 인증 API 요청 URL */ @Override protected String getAuthorizationBaseUrl() { return "https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent"; } /** * 사용자 정보 요청 URL 반환 메서드 * * @return [String] 사용자 정보 요청 URL */ @Override protected String getUserInfoEndPoint() { return "https://www.googleapis.com/oauth2/v3/userinfo"; } }
The full, organized code is as above.
This is very similar to the implementation of Naver's authentication module. The methods that need to be implemented or overridden are all the same. Through AuthModule, replacing things with a common module and overriding when necessary made it clear just how effectively you can adapt to multiple platforms this way. I got a renewed appreciation for why object orientation is so useful for maintainability.
With this, the implementation of the Google authentication module is complete. At this stage, since it's still in development, it can only be used with designated test IDs. You need to register a test account in the API settings in order to test login with that account. Once the application is approved after review, login will be available with all IDs.
Google was a bit tricky to develop against. Since Google offers so many services, the documentation is massive, and there wasn't a document that explained things simply and concisely, so I had to search around in a lot of different places.
