[OAuth2.0] Building an OAuth2.0 Authorization Server with ScribeJAVA - 7. Applying for the GitHub OAuth Service and Implementing the 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.
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.
| Method | Method Type | Description | Implementation Needed? |
|---|---|---|---|
| getAuthorizationUrl | abstract | Returns the authentication URL | Y |
| getAccessToken | Returns the access token | Y | |
| getRefreshAccessToken | Refreshes and returns the access token | ||
| getUserInfo | Returns the user info response | Y | |
| 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 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; }
| 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 | GithubAuthModule | 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://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}
| 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 |
- 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
| 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 |
| {:redirect_uri} | path | String | Y | The Callback URL |
| {:code} | path | String | Y | The authorization code |
- Response
JSON
{ "access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a", "scope": "repo,gist", "token_type": "bearer" }
| parameter | data | description |
|---|---|---|
| access_token | String | The access token |
| token_type | String | The token type |
| scope | String | The 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}
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:access_token} | header | String | Y | The 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
| parameter | type | data | required | description |
|---|---|---|---|---|
| {:client_id} | path | String | Y | The API key |
| {:auth} | header | String | Y | Basic 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.
