Implement OAuth without using client library (Java)

I don't know how many brews it is, but this is an article on how to use OAuth. There are as many OAuth client libraries in Java as there are stars, including Google's own, but here I will touch OAuth without using the client library for study. In addition, I think that there are many good articles about the mechanism of OAuth itself, so I will not explain it here.

What to do in this article

References

Using OAuth 2.0 for Web Server Applications OpenID Connect

OAuth

Preparation

You need to register your OAuth credentials with Google.

Developers Console

Create Credentials-> OAuth Client ID-> Web Application

The name is appropriate. Added http: // localhost: 8080 / auth to the approved redirect URI. The value added here will be the value that can be used as the redirect destination of the request. This time we will only use it for development, so specify localhost. It should not be used in production.

Dependencies

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.3.RELEASE")
    }
}

apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

dependencies {
    compile(
            'org.springframework.boot:spring-boot-devtools',
            'org.springframework.boot:spring-boot-starter-web',
            'org.springframework.boot:spring-boot-starter-thymeleaf',

            'com.google.guava:guava:25.1-jre',
            'com.google.http-client:google-http-client:1.23.0',
            'com.google.http-client:google-http-client-gson:1.23.0',
            'com.google.code.gson:gson:2.8.5',
    )
}

Anything is fine, but this time I will use Spring. ~~ Guava is used for Base64 and ~~ HttpClient is used for HTTP request simplification. Use Gson to parse JSON.

Addendum: Base64 is supported from Java 8 It's completely out of focus.

Login screen

Create a login screen.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>

<a th:href="${endpoint}">login</a>

</body>
</html>
private static final String clientId = checkNotNull( System.getenv( "CLIENT_ID" ) );

@GetMapping( "/login" )
public String login( Model model ) {

    var endpoint = "https://accounts.google.com/o/oauth2/v2/auth?"
                   + "client_id=" + clientId + "&"
                   + "redirect_uri="
                   + UrlEscapers.urlFormParameterEscaper().escape( "http://localhost:8080/auth" )
                   + "&"
                   + "access_type=" + "online" + "&"
                   + "state=" + "test_state" + "&"
                   + "response_type=" + "code" + "&"
                   + "scope="
                   + UrlEscapers.urlFormParameterEscaper().escape( "email profile" );
    model.addAttribute( "endpoint", endpoint );
    return "login";
}

https://accounts.google.com/o/oauth2/v2/auth is the endpoint for the Google OAuth API. Set the required information in the URL query parameter.

parameter
client_id An ID that identifies the client. Use the value you got in the developer console earlier. Here, it is obtained from the environment variable.
redirect_uri The redirect destination. Use the value set in the developer console.
access_type This time I will use a browser, soonlineIs specified.
state stateThe value itself can be set freely. MostlynonceIt is used for such as. This time it is simply a fixed value.
response_type How to receive a response. On the server sidecodeIt is fixed.
scope Specify what information you can get with this request. hereemailandprofileIs set. For available valuesdocumentSee.

When the user follows the link generated here, they will be taken to the Google login screen. The first time you allow the application to access your user information, a confirmation dialog is displayed. When the user completes the login process, they will be redirected to redirect_url. You can request an access token using the code included in the query parameter of the URL.

Get token

@GetMapping( "/auth" )
public String auth( HttpServletRequest request, Model model ) throws IOException {

    var params = parseQueryParams( request );
    checkState( Objects.equals( params.get( "state" ), "test_state" ) );
    var code = URLDecoder.decode( checkNotNull( params.get( "code" ) ), StandardCharsets.UTF_8 );

    // ...

Get the code and state from the query parameters. state verifies that the value used in the previous request is returned as is. Note that you may need to decode the URL.

var response = httpPost(
        "https://www.googleapis.com/oauth2/v4/token",
        Map.of(
                "code", code,
                "client_id", clientId,
                "client_secret", clientSecret,
                "redirect_uri", "http://localhost:8080/auth",
                "grant_type", "authorization_code"
        )
).parseAsString();
var responseAsMap = parseJson( response );

Send a POST request to the URL for token acquisition. It was v2 a while ago, but for some reason this is v4. I don't care because it is as per the official document.

parameter
code Obtained abovecodeIs sent as it is.
client_id Client ID.
client_secret Client secret. Like the ID, set the one specified in the developer console.
redirect_uri This is not a redirect destination, but for verification. Set it in the console and set the redirect destination used in the previous request again.
grant_type Fixed valueauthorization_code

If the request is recognized as successful, a JWT-formatted token is returned.

ID token parsing

The JSON Web Token is divided into three parts, the header, body, and signature separated by ., so get each part.

var idTokenList = Splitter.on( "." ).splitToList( idToken );

var header = parseJson( base64ToStr( idTokenList.get( 0 ) ) );
var body = parseJson( base64ToStr( idTokenList.get( 1 ) ) );
var sign = idTokenList.get( 2 );

The header and body parts are Base64-encoded JSON. If you do not verify the result, you can get the value with just this, but since it is a big deal, let's also verify the token.

Get the key

Get the Google public key to use for verification. Get the key location from https://accounts.google.com/.well-known/openid-configuration.

var response = httpGet( "https://accounts.google.com/.well-known/openid-configuration" ).parseAsString();
var responseAsMap = parseNestedJson( response );
var jwks = responseAsMap.get( "jwks_uri" ).toString();

Send a GET request to this URI to get the JSON.

var keyResponse = httpGet( jwks ).parseAsString();
var keysResponseObj = parseJsonAs( keyResponse, Keys.class );
return keysResponseObj.keys.stream()
                           .filter( k -> k.kid.equals( kid ) )
                           .findAny()
                           .orElseThrow();

Multiple encryption keys are stored in the keys array. Look for a kid that matches the kid in the header of the ID token.

var signature = Signature.getInstance( "SHA256withRSA" );
signature.initVerify( KeyFactory.getInstance( "RSA" ).generatePublic( new RSAPublicKeySpec(
        new BigInteger( 1, base64ToByte( n ) ),
        new BigInteger( 1, base64ToByte( e ) )
) ) );
signature.update( contentToVerify.getBytes( StandardCharsets.UTF_8 ) );
return signature.verify( base64ToByte( sign ) );

Convert the key JSON to Java format and make sure it is well known. Actually, the key format should also be obtained from JSON, but here it is a fixed value of RSA.

After confirming the signature, the next step is to verify the parameters.

checkState( Set.of( "https://accounts.google.com", "accounts.google.com" ).contains( body.get( "iss" ) ) );

Make sure the issuer, ʻiss, is legitimate. For Google, it's either https://accounts.google.com, ʻaccounts.google.com.

checkState( Objects.equals( body.get( "aud" ), clientId ) );

Make sure the audience, ʻaud`, matches the client ID.

var now = System.currentTimeMillis();
checkState( now <= Long.parseLong( body.get( "exp" ) ) * 1000 + 1000 );
checkState( now >= Long.parseLong( body.get( "iat" ) ) * 1000 - 1000 );

Finally, make sure the key is within the expiration date and the signing time is valid. The value is in seconds. The reason for adding and subtracting 1000 is to absorb the time lag with the server.

Get parameters

Now that we have verified that the ID token is valid, we will get the value. Here, the email address, name, and URL of the face image are acquired and set in the model.

model.addAttribute( "email", id.get( "email" ) )
     .addAttribute( "name", id.get( "name" ) )
     .addAttribute( "picture", id.get( "picture" ) );
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Result</title>
</head>
<body>

<img th:src="${picture}">
<p th:text="'Email: ' + ${email}"></p>
<p th:text="'Name:  ' + ${name}"></p>

<a th:href="${back}">back</a>

</body>
</html>

To use Google's various APIs, use the ʻaccess_token and refresh_token` included in the JWT to send each request.

Operation check

Go to localhost: 8080 / login.

1.PNG

2.PNG

Go to Google and authenticate.

3.PNG

I got the face image, email address, and name.

Recommended Posts

Implement OAuth without using client library (Java)
SELECT data using client library in BigQuery
[Python] Deep Learning: I tried to implement deep learning (DBN, SDA) without using a library.
[Google Cloud Platform] Use Google Cloud API using API Client Library
Modulo without using%
Read files on GCS using Cloud Storage Client Library
[Django] Implement image file upload function without using model
Bubble sort without using sort
Write FizzBuzz without using "="
Quicksort without using sort
Try building a neural network in Python without using a library