HelloKoding

Practical coding guides

Login with OAuth2 and OpenId Connect in Spring Boot with ScribeJava

ScribeJava is an OAuth library for Java that helps you to ease the process of adding Login options for a user to OAuth2 and OpenId Connect providers such as Github, Google, Facebook, LinkedIn, and Discord. When comparing with Spring Security OAuth2, ScribeJava has a different approach for configuring custom providers

In Spring Security OAuth2 and Spring Boot, you can add a new Login options by configure only 2 properties in application.properties or application.yml for the providers that follow the the OAuth2 specification. However, when they don’t, it is required you to deep dive into Spring Security to provide the customization

On the other side, ScribeJava tries to work for all providers and offers built-in configuration classes for each provider, separately. Furthermore, you are not required to make a deep dive, adding a new configuration for a custom provider is pretty straight forward and clean

This tutorial will walk you through the steps of creating OAuth2 and OpenId Connect web clients example with the Login options to Github, Google, Facebook, Okta, LinkedIn, and Discord in Spring Boot and ScribeJava. We will try to bring the best feature of Spring Security OAuth2 auto-configuration in Spring Boot into this implementation. Apart from that, the user session will be managed in Redis instead of application server memory as in practice, your application will be run in a distributed mode with 2 or more instances reside behind a load balancer

OAuth2 and OpenId Connect

  • OAuth represents Open Authorization. It is an authorization framework enabling a third-party application to obtain limited access to an HTTP service on behalf of a resource owner
  • OpenId Connect is an extension of OAuth2 and designed for authentication only. While OAuth2 has no definition on the format of the token, OpenId Connect uses JWT (JSON Web Token)

What you’ll build

An index page with the options to allow user login to OAuth2 and OpenId Connect providers

OAuth2 and OpenId Connect Login

Redirect user to Login form of the respective provider when the Login link is clicked

OAuth2 and OpenId Connect Login

Redirect user back to the index page when logging in successfully, save session data into Redis, show user full name and the logout function on the same page

OAuth2 and OpenId Connect Login

When a user clicks log out, clear Redis session data, trigger the revoke token API of the provider if available, and show again the login options

What you’ll need

  • Your favorite IDE or Editor
  • JDK 8+ or OpenJDK 8+
  • Maven 3+
  • Redis Server

Init project structure

Besides using IDE, you can create and init a new Spring Boot project with Spring CLI or Spring Initializr. Learn more about using these tools here

The final project structure as below

├── src
│   └── main
│       ├── java
│       │   └── com
│       │       └── hellokoding
│       │           └── springboot
│       │               └── oauth2
│       │                   ├── OAuth2Application.java
│       │                   ├── OAuth2Controller.java
│       │                   ├── OAuth2Properties.java
│       │                   ├── OAuth2ServiceFactory.java
│       │                   ├── RedisConfig.java
│       │                   ├── SecurityFilter.java
│       │                   └── UserSessionInRedis.java
│       └── resources
│           ├── static
│           │   └── index.html
│           └── application.yml
└── pom.xml

Project dependencies

Add spring-boot-starter-web and scribejava-apis into your project as a dependency on pom.xml or build.gradle file

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.github.scribejava</groupId>
    <artifactId>scribejava-apis</artifactId>
    <version>6.9.0</version>
</dependency>

As we manage user session in Redis, spring-boot-starter-data-redis dependency is also required

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

And last but not least, add lombok dependency for reducing boilerplate code when you define POJO classes and constructors

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>

You can find the full pom.xml file as below

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hellokoding.springboot</groupId>
    <artifactId>scribejava-oauth2</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.2.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.scribejava</groupId>
            <artifactId>scribejava-apis</artifactId>
            <version>6.9.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

Required OAuth2 Properties

The following are required properties you need to configure an OAuth2 Client

  • Client id, client secret, and scopes of user data access

    Scopes are predefined strings by providers and may be different across them

    You need a developer account to create an OAuth2 application on the provider site to obtain client id, client secret, and scopes

  • Redirect URI for forwarding authorization code and state from server to client

    The OAuth client is required to provide the Redirect URI and declare it on the OAuth application. We will define a controller to handle the redirect response in the latter part of this tutorial. It will follow the same format as in Spring Security: {baseUrl}/{action}/oauth2/code/{registrationId}, for example, http://localhost:8081/login/oauth2/code/github or http://localhost:8081/login/oauth2/code/google

  • Provider authorization URI, token URI, and user info URI

    You can find provider URIs on its documentation

Implement Auto-Configuration for OAuth2 Properties

We use ConfigurationProperties in Spring Boot to bind external properties from application.yml file into a Java class

application.yml

server:
  port: 8081

oauth2:
  client:
    registration:
      github:
        clientId: ${GITHUB_CLIENT_ID}
        clientSecret: ${GITHUB_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/github
        scope: read:user

      google:
        clientId: ${GOOGLE_CLIENT_ID}
        clientSecret: ${GOOGLE_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/google
        scope: email profile

      facebook:
        clientId: ${FACEBOOK_CLIENT_ID}
        clientSecret: ${FACEBOOK_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/facebook
        scope: email public_profile

      okta:
        clientId: ${OKTA_CLIENT_ID}
        clientSecret: ${OKTA_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/okta
        scope: openid profile email

      linkedin:
        clientId: ${LINKEDIN_CLIENT_ID}
        clientSecret: ${LINKEDIN_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/linkedin
        scope: r_liteprofile r_emailaddress

      discord:
        clientId: ${DISCORD_CLIENT_ID}
        clientSecret: ${DISCORD_CLIENT_SECRET}
        redirectUri: http://localhost:8081/login/oauth2/code/discord
        scope: identify email

    provider:
      github:
        name: github
        authorizationUri: https://github.com/login/oauth/authorize
        tokenUri: https://github.com/login/oauth/access_token
        userInfoUri: https://api.github.com/user

      google:
        name: google
        authorizationUri: https://accounts.google.com/o/oauth2/v2/auth
        tokenUri: https://oauth2.googleapis.com/token
        userInfoUri: https://openidconnect.googleapis.com/v1/userinfo
        revokeTokenUri: https://oauth2.googleapis.com/revoke

      facebook:
        name: facebook
        authorizationUri: https://graph.facebook.com/oauth/authorize
        tokenUri: https://graph.facebook.com/oauth/access_token
        userInfoUri: https://graph.facebook.com/me?fields=id,name,email
        revokePermissionUri: https://graph.facebook.com/{user-id}/permissions

      okta:
        name: okta
        authorizationUri: https://${OKTA_SUBDOMAIN}.okta.com/oauth2/default/v1/authorize
        tokenUri: https://${OKTA_SUBDOMAIN}.okta.com/oauth2/default/v1/token
        userInfoUri: https://${OKTA_SUBDOMAIN}.okta.com/oauth2/default/v1/userinfo
        revokeTokenUri: https://${OKTA_SUBDOMAIN}.okta.com/oauth2/default/v1/revoke

      linkedin:
        name: linkedin
        authorizationUri: https://www.linkedin.com/oauth/v2/authorization
        tokenUri: https://www.linkedin.com/oauth/v2/accessToken
        userInfoUri: https://api.linkedin.com/v2/me
        userNameAttribute: localizedFirstName

      discord:
        name: discord
        authorizationUri: https://discord.com/api/oauth2/authorize
        tokenUri: https://discord.com/api/oauth2/token
        userInfoUri: https://discord.com/api/users/@me
        revokeTokenUri: https://discord.com/api/oauth2/token/revoke
        userNameAttribute: username

OAuth2Properties.java

package com.hellokoding.springboot.oauth2;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
@ConfigurationProperties(prefix = "oauth2.client")
@Getter
public class OAuth2Properties {
    private final Map<String, Registration> registration = new HashMap<>();
    private final Map<String, Provider> provider = new HashMap<>();

    @Getter @Setter
    public static class Registration {
        private String clientId;
        private String clientSecret;
        private String redirectUri;
        private String scope;
        private String authorizationGrantType = "code";
    }

    @Getter @Setter
    public static class Provider {
        private String name;
        private String authorizationUri;
        private String tokenUri;
        private String userInfoUri;
        private String revokeTokenUri;
        private String revokePermissionUri;
        private String userNameAttribute = "name";
    }
}

The core part of ScribeJava is OAuth20Service. It is a service class that executes all of the operations against an OAuth2 or OpenId Connect provider

OAuth20Service requires an instance of DefaultApi20, an object contains all of the provider endpoints such as Authorization, Access token, and User info URIs. Moreover, other important properties of an OAuth2 client are also required like Client id, Client secret, and Redirect URI

In the following, we define OAuth2Api and OAuth2ServiceFactory. OAuth2Api is extended from DefaultApi20. OAuth2ServiceFactory is used to create an OAuth20Service object

OAuth2ServiceFactory.java

package com.hellokoding.springboot.oauth2;

import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.builder.api.DefaultApi20;
import com.github.scribejava.core.extractors.OAuth2AccessTokenExtractor;
import com.github.scribejava.core.extractors.OAuth2AccessTokenJsonExtractor;
import com.github.scribejava.core.extractors.TokenExtractor;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.oauth.OAuth20Service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@RequiredArgsConstructor
@Component
public class OAuth2ServiceFactory {
    private static Map<String, OAuth20Service> services = new HashMap<>();
    private final OAuth2Properties oAuth2Properties;

    public OAuth20Service getService(String serviceId) {
        if (services.containsKey(serviceId))
            return services.get(serviceId);

        OAuth2Properties.Registration registration = oAuth2Properties.getRegistration().get(serviceId);
        OAuth2Properties.Provider provider = oAuth2Properties.getProvider().get(serviceId);

        OAuth20Service oAuth20Service = new ServiceBuilder(registration.getClientId())
            .apiSecret(registration.getClientSecret())
            .callback(registration.getRedirectUri())
            .defaultScope(registration.getScope())
            .responseType(registration.getAuthorizationGrantType())
            .userAgent("HelloKoding")
            .build(new OAuth2Api(provider));
        services.put(serviceId, oAuth20Service);

        return oAuth20Service;
    }

    @RequiredArgsConstructor
    class OAuth2Api extends DefaultApi20 {
        private final OAuth2Properties.Provider provider;

        @Override
        public String getAccessTokenEndpoint() {
            return provider.getTokenUri();
        }

        @Override
        public String getAuthorizationBaseUrl() {
            return provider.getAuthorizationUri();
        }

        @Override
        public String getRevokeTokenEndpoint() {
            return provider.getRevokeTokenUri();
        }

        @Override
        public TokenExtractor<OAuth2AccessToken> getAccessTokenExtractor() {
            if ("github".equalsIgnoreCase(provider.getName()))
                return OAuth2AccessTokenExtractor.instance();

            return OAuth2AccessTokenJsonExtractor.instance();
        }

        public String getUserInfoEndpoint() {
            return provider.getUserInfoUri();
        }

        public String getUserNameAttribute() {
            return provider.getUserNameAttribute();
        }

        public String getRevokePermissionEndpoint() {
            return provider.getRevokePermissionUri();
        }
    }
}

OAuth2ServiceFactory follows the Factory design pattern. It can create different kinds of OAuth2Service respect with multiple providers. It is based on a serviceId parameter to collect the respective OAuth2 client and provider properties which are provided by OAuth2Properties and OAuth2Api

Manage user session with Redis

When you included spring-boot-starter-data-redis dependency, Spring Boot will provide a RedisTemplate bean which can be used for executing operations against Redis. By default, the RedisTemplate will use JdkSerializationRedisSerializer for serializing the data key which can cause trouble when you need to search a key in Redis later. So here we define another RedisTemplate bean to use a StringRedisSerializer instead

RedisConfig.java

package com.hellokoding.springboot.oauth2;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setKeySerializer(RedisSerializer.string());
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }
}

Moving forward, we will define a class that leverage the above RedisTemplate to create APIs for saving, getting, and invalidating user session data

UserSessionInRedis.java

package com.hellokoding.springboot.oauth2;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Set;

@Component
@RequiredArgsConstructor
public class UserSessionInRedis {
    private final RedisTemplate<String, Object> redisTemplate;
    private final HttpServletRequest httpServletRequest;
    private final HttpServletResponse httpServletResponse;

    public void put(String key, Object value, Duration timeout) {
        redisTemplate.opsForValue().set(buildSessionKey(key), value, timeout);
    }

    public Object get(String key) {
        return redisTemplate.opsForValue().get(buildSessionKey(key));
    }

    public void invalidate() {
        Set<String> keys = redisTemplate.keys(httpServletRequest.getSession().getId().concat("*"));
        for (String key : keys) {
            redisTemplate.expire(key, Duration.ZERO);
        }

        Cookie sessionCookie = WebUtils.getCookie(httpServletRequest, "JSESSIONID");
        sessionCookie.setMaxAge(0);
        httpServletResponse.addCookie(sessionCookie);

        httpServletRequest.getSession().invalidate();
    }

    private String buildSessionKey(String key) {
        return String.format("%s-%s", httpServletRequest.getSession().getId(), key);
    }
}

The user session data key is built based on HttpServletRequest session id. When invalidating a session, we need to expire all data key in Redis started with that session id. We also expire the JSESSIONID cookie and invalidate the current HttpServletRequest session object

Define controllers for handling OAuth2 requests and responses

This is the core part of our implementation. We are going to wire all the components together to create a web controller for serving OAuth2 requests and responses

In the following, we will define 4 controllers for handling the login request, receiving authorization code from OAuth2 provider, retrieving current authenticated user info, and a logout function to invalidate the current user session and trigger the revoke token API of provider

OAuth2Controller.java

package com.hellokoding.springboot.oauth2;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthRequest;
import com.github.scribejava.core.model.Verb;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.github.scribejava.core.revoke.TokenTypeHint;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.view.RedirectView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ExecutionException;

@RestController
@RequiredArgsConstructor
public class OAuth2Controller {
    private final OAuth2ServiceFactory oAuth2ServiceFactory;
    private final ObjectMapper objectMapper;
    private final UserSessionInRedis userSession;
    private final HttpServletResponse httpServletResponse;

    private static final String KEY_USER = "user";
    private static final String KEY_STATE = "state";
    private static final String KEY_USERNAME = "username";
    private static final String KEY_ACCESS_TOKEN = "accessToken";
    private static final String KEY_SERVICE_ID = "serviceId";

    @GetMapping("/oauth2/authorization/{serviceId}")
    public RedirectView oauth2Login(@PathVariable String serviceId) {
        String state = UUID.randomUUID().toString();
        userSession.put(KEY_STATE, state, Duration.of(60, ChronoUnit.SECONDS));
        return new RedirectView(oAuth2ServiceFactory.getService(serviceId).getAuthorizationUrl(state));
    }

    @GetMapping("/login/oauth2/code/{serviceId}")
    public RedirectView oauth2Code(@PathVariable String serviceId, String code, String state) throws InterruptedException, ExecutionException, IOException {
        if (!Objects.equals(state, userSession.get(KEY_STATE))) {
            httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        } else {
            OAuth20Service oAuth20Service = oAuth2ServiceFactory.getService(serviceId);
            OAuth2AccessToken accessToken = oAuth20Service.getAccessToken(code);

            OAuth2ServiceFactory.OAuth2Api oAuth2Api = (OAuth2ServiceFactory.OAuth2Api)oAuth20Service.getApi();
            final OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, oAuth2Api.getUserInfoEndpoint());
            oAuth20Service.signRequest(accessToken, oAuthRequest);
            Map<String, String> map = objectMapper.readValue(oAuth20Service.execute(oAuthRequest).getBody(), Map.class);
            map.put(KEY_ACCESS_TOKEN,  accessToken.getAccessToken());
            map.put(KEY_SERVICE_ID, serviceId);
            map.put(KEY_USERNAME, map.get(oAuth2Api.getUserNameAttribute()));
            int expiresIn = Optional.ofNullable(accessToken.getExpiresIn()).orElse(3600);
            userSession.put(KEY_USER, map, Duration.of(expiresIn, ChronoUnit.SECONDS));
        }

        return new RedirectView("/");
    }

    @GetMapping("/user")
    public Map user(HttpServletRequest request) {
        Map<String, Object> user = ((Map) userSession.get(KEY_USER));
        return Objects.isNull(user) ? null : Collections.singletonMap(KEY_USERNAME, user.get(KEY_USERNAME));
    }

    @PostMapping("/logout")
    public void logout() throws InterruptedException, ExecutionException, IOException {
        Map<String, String> user = (Map) userSession.get(KEY_USER);
        if (Objects.nonNull(user)) {
            String serviceId = user.get(KEY_SERVICE_ID);
            OAuth20Service oAuth20Service = oAuth2ServiceFactory.getService(serviceId);
            if (Objects.equals("facebook", serviceId)) {
                revokeFacebookPermission(oAuth20Service, user);
            } else if (!StringUtils.isEmpty(oAuth20Service.getApi().getRevokeTokenEndpoint())) {
                oAuth20Service.revokeToken(user.get(KEY_ACCESS_TOKEN), TokenTypeHint.ACCESS_TOKEN);
            }

            userSession.invalidate();
        }
    }

    void revokeFacebookPermission(OAuth20Service oAuth20Service, Map<String, String> user) throws InterruptedException, ExecutionException, IOException {
        OAuth2ServiceFactory.OAuth2Api oAuth2Api = (OAuth2ServiceFactory.OAuth2Api)oAuth20Service.getApi();
        String endPoint = oAuth2Api.getRevokePermissionEndpoint().replace("{user-id}", user.get("id"));
        final OAuthRequest oAuthRequest = new OAuthRequest(Verb.DELETE, endPoint);
        oAuth20Service.signRequest(user.get(KEY_ACCESS_TOKEN), oAuthRequest);
        oAuth20Service.execute(oAuthRequest);
    }
}
  • The oauth2Login method defines a login controller at /oauth2/authorization/{serviceId} URI The {serviceId} value can be “github”, “google”,…mapped with our definition in application.yml file

    The state parameter is used to provide the CSRF protection which you will see in the later controller

    oAuth2ServiceFactory.getService(serviceId) creates if not existing and returns an OAuth2Service which is used to build authorization URL to the respective provider

  • The oauth2Code method defines a controller at /login/oauth2/code/{serviceId} URI for receiving authorization code from provider. That URI is mapped with the redirectUri defined in application.yml and with the redirect URI you entered into OAuth2 application on the provider site

    The code parameter is mentioned in the above authorization code

    The state parameter is returned by the provider so we can compare it with the original state created by the login controller. If they are not matched, probably it is a CSRF attack, hence 401 status should be returned to user

    Now we have the code, we can use it to exchange access token via oAuth20Service.getAccessToken(code) and start to take the returned access token to consume provider APIs such as user info

    To conclude the method, we save user info into Redis so it can be reused for subsequence requests

  • The user method defines a controller at /user URI for retrieving current authenticated user info. userSession.get(KEY_USER) get the data from Redis and return it to user
  • The logout method defines a controller at /logout URI for serving the log out function. It executes the revoke token API of provider the invalidate the Redis session with userSession.invalidate()

    Not all providers offer the revoke token API. You can check their documentation for more details. Facebook provides a revoke permission API instead when trigger it, the OAuth2 app of Facebook will ask user again for granting permission to our application in the next login

Create OAuth2 web clients

Create index.html file inside src/main/resources/static to define OAuth2 web clients

index.html

<!doctype html>
<html lang="en">
    <head>
        <title>Login with OAuth2 / OpenId Connect</title>
        <style>
            body {
                margin: 50px 50px;
            }

            a {
                display: block;
                line-height: 40px;
            }
        </style>
    </head>
    <body>
        <h1>Login with OAuth2 / OpenId Connect</h1>
        <div class="container">
            <div id="login" style="display:block">
                <a href="/oauth2/authorization/github">Login with Github</a>
                <a href="/oauth2/authorization/google">Login with Google</a>
                <a href="/oauth2/authorization/facebook">Login with Facebook</a>
                <a href="/oauth2/authorization/okta">Login with Okta</a>
                <a href="/oauth2/authorization/linkedin">Login with LinkedIn</a>
                <a href="/oauth2/authorization/discord">Login with Discord</a>
            </div>
            <div id="welcome" style="display:none">
                Welcome <span id="name"></span> | <button onClick="logout()">Logout</button>
            </div>
        </div>
        <script>
            fetch('/user')
                .then((response) => {
                    if (response.ok) {
                        return response.json();
                    } else {
                        throw new Error('Something went wrong');
                    }
                })
                .then((responseJson) => {
                    document.getElementById('name').innerText = responseJson.username;
                    document.getElementById('login').style.display = 'none';
                    document.getElementById('welcome').style.display = 'block';
                })
                .catch((error) => {
                    console.error('Error: ', error);
                });

            function logout() {
                fetch('/logout', {
                        method: 'POST',
                        headers: {
                            'X-XSRF-TOKEN': getCookie('XSRF-TOKEN')
                        }
                    })
                    .then((response) => {
                        if (response.ok) {
                            document.getElementById('login').style.display = 'block';
                            document.getElementById('welcome').style.display = 'none';
                        } else {
                            throw new Error('Something went wrong');
                        }
                    })
                    .catch((error) => {
                        console.error('Error: ', error);
                    });
            }

            function getCookie(name) {
                var v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
                return v ? v[2] : null;
            }
        </script>
    </body>
</html>
  • In index.html, we defined login to Github, Google, Facebook, Okta, LinkedIn, and Okta options with a link point to our login controller /oauth2/authorization/github, /oauth2/authorization/google, /oauth2/authorization/facebook, /oauth2/authorization/okta, /oauth2/authorization/linkedin, /oauth2/authorization/discord URIs respectively
  • For CSRF protection for the logout request from web users, we compare the token value of XSRF-TOKEN cookie and X-XSRF-TOKEN header. If they are matched then it’s safe to go. The security logic is defined in SecurityFilter

SecurityFilter.java

package com.hellokoding.springboot.oauth2;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;

@Component
@WebFilter("/*")
public class SecurityFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        if ("GET".equalsIgnoreCase(httpServletRequest.getMethod())) {
            Cookie cookie = new Cookie("XSRF-TOKEN", UUID.randomUUID().toString());
            cookie.setHttpOnly(false);
            httpServletResponse.addCookie(cookie);
        } else if ("POST".equalsIgnoreCase(httpServletRequest.getMethod())) {
            Cookie cookie = WebUtils.getCookie(httpServletRequest, "XSRF-TOKEN");
            String csrfHeader = httpServletRequest.getHeader("X-XSRF-TOKEN");
            if (!Objects.equals(csrfHeader, cookie.getValue())) {
                httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value());
                return;
            }
        }

        chain.doFilter(request, response);
    }
}

Create Spring Boot application entry point

Create OAuth2Application.java as a Spring Boot application entry point

OAuth2Application.java

package com.hellokoding.springboot.oauth2;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OAuth2Application {
    public static void main(String[] args) {
        SpringApplication.run(OAuth2Application.class, args);
    }
}

Run and Test

You can run the application by typing the following command on the terminal console at the project root directory, make sure your Redis server is ready on local

$ mvn clean spring-boot:run

Access to http://localhost:8081 on your web browser to explore the app

Conclusion

That’s it, folks! We created a practical example of adding Login to OAuth2 and OpenId Connect options in Spring Boot by using ScribeJava. The user session is managed by Redis instead of server memory. Adding a new provider is super fast with some lines of settings in the application.yml

You can find the complete source code at https://github.com/hellokoding/hellokoding-courses/blob/master/springboot-examples/scribejava-oauth2

Follow HelloKoding