Building a GitHub OAuth application in Java allows users to authenticate with your application using their GitHub credentials. This guide covers the complete implementation from setup to production deployment.
OAuth 2.0 Flow Overview
Authorization Code Flow:
- User clicks "Login with GitHub"
- Redirect to GitHub authorization endpoint
- User approves application access
- GitHub redirects back with authorization code
- Application exchanges code for access token
- Application uses token to access GitHub API
Prerequisites and Setup
1. Register GitHub OAuth App
- Go to GitHub Settings → Developer settings → OAuth Apps
- Click "New OAuth App"
- Fill in application details:
- Application name: My Java App
- Homepage URL: http://localhost:8080
- Authorization callback URL: http://localhost:8080/auth/github/callback
2. Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<jackson.version>2.15.2</jackson.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Session Management -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring-boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
3. Application Configuration
# application.yml
github:
oauth:
client-id: ${GITHUB_CLIENT_ID:your_client_id}
client-secret: ${GITHUB_CLIENT_SECRET:your_client_secret}
redirect-uri: http://localhost:8080/auth/github/callback
authorization-uri: https://github.com/login/oauth/authorize
token-uri: https://github.com/login/oauth/access_token
user-info-uri: https://api.github.com/user
user-emails-uri: https://api.github.com/user/emails
server:
port: 8080
servlet:
session:
timeout: 30m
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: user:email,read:user
Core Implementation
1. Configuration Properties
// GitHubOAuthProperties.java
package com.example.githubauth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "github.oauth")
public class GitHubOAuthProperties {
private String clientId;
private String clientSecret;
private String redirectUri;
private String authorizationUri;
private String tokenUri;
private String userInfoUri;
private String userEmailsUri;
// Getters and setters
public String getClientId() { return clientId; }
public void setClientId(String clientId) { this.clientId = clientId; }
public String getClientSecret() { return clientSecret; }
public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; }
public String getRedirectUri() { return redirectUri; }
public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; }
public String getAuthorizationUri() { return authorizationUri; }
public void setAuthorizationUri(String authorizationUri) { this.authorizationUri = authorizationUri; }
public String getTokenUri() { return tokenUri; }
public void setTokenUri(String tokenUri) { this.tokenUri = tokenUri; }
public String getUserInfoUri() { return userInfoUri; }
public void setUserInfoUri(String userInfoUri) { this.userInfoUri = userInfoUri; }
public String getUserEmailsUri() { return userEmailsUri; }
public void setUserEmailsUri(String userEmailsUri) { this.userEmailsUri = userEmailsUri; }
}
2. OAuth Service
// GitHubOAuthService.java
package com.example.githubauth.service;
import com.example.githubauth.config.GitHubOAuthProperties;
import com.example.githubauth.model.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
@Service
public class GitHubOAuthService {
private static final Logger logger = LoggerFactory.getLogger(GitHubOAuthService.class);
private final GitHubOAuthProperties properties;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public GitHubOAuthService(GitHubOAuthProperties properties, RestTemplate restTemplate, ObjectMapper objectMapper) {
this.properties = properties;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
/**
* Generate GitHub authorization URL
*/
public String getAuthorizationUrl(String state) {
return UriComponentsBuilder.fromHttpUrl(properties.getAuthorizationUri())
.queryParam("client_id", properties.getClientId())
.queryParam("redirect_uri", properties.getRedirectUri())
.queryParam("scope", "user:email,read:user")
.queryParam("state", state)
.queryParam("allow_signup", "true")
.build()
.toUriString();
}
/**
* Exchange authorization code for access token
*/
public String exchangeCodeForToken(String code) throws OAuthException {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", properties.getClientId());
body.add("client_secret", properties.getClientSecret());
body.add("code", code);
body.add("redirect_uri", properties.getRedirectUri());
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
logger.info("Exchanging code for token with GitHub...");
ResponseEntity<String> response = restTemplate.postForEntity(
properties.getTokenUri(), request, String.class);
if (!response.getStatusCode().is2xxSuccess()) {
throw new OAuthException("Failed to exchange code for token: " + response.getStatusCode());
}
// Parse the response to extract access token
JsonNode jsonNode = objectMapper.readTree(response.getBody());
String accessToken = jsonNode.get("access_token").asText();
if (accessToken == null || accessToken.isEmpty()) {
throw new OAuthException("Access token not found in response");
}
logger.info("Successfully obtained access token");
return accessToken;
} catch (Exception e) {
logger.error("Error exchanging code for token", e);
throw new OAuthException("Failed to exchange authorization code", e);
}
}
/**
* Get user information from GitHub API
*/
public GitHubUser getUserInfo(String accessToken) throws OAuthException {
try {
HttpHeaders headers = createHeadersWithToken(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
logger.info("Fetching user info from GitHub...");
ResponseEntity<String> response = restTemplate.exchange(
properties.getUserInfoUri(), HttpMethod.GET, entity, String.class);
if (!response.getStatusCode().is2xxSuccess()) {
throw new OAuthException("Failed to get user info: " + response.getStatusCode());
}
GitHubUser user = objectMapper.readValue(response.getBody(), GitHubUser.class);
logger.info("Retrieved user info for: {}", user.getLogin());
return user;
} catch (Exception e) {
logger.error("Error fetching user info", e);
throw new OAuthException("Failed to get user information", e);
}
}
/**
* Get user email addresses from GitHub API
*/
public GitHubEmail[] getUserEmails(String accessToken) throws OAuthException {
try {
HttpHeaders headers = createHeadersWithToken(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
logger.info("Fetching user emails from GitHub...");
ResponseEntity<String> response = restTemplate.exchange(
properties.getUserEmailsUri(), HttpMethod.GET, entity, String.class);
if (!response.getStatusCode().is2xxSuccess()) {
throw new OAuthException("Failed to get user emails: " + response.getStatusCode());
}
GitHubEmail[] emails = objectMapper.readValue(response.getBody(), GitHubEmail[].class);
logger.info("Retrieved {} email addresses", emails.length);
return emails;
} catch (Exception e) {
logger.error("Error fetching user emails", e);
throw new OAuthException("Failed to get user emails", e);
}
}
/**
* Get user's public repositories
*/
public GitHubRepo[] getUserRepos(String accessToken) throws OAuthException {
try {
HttpHeaders headers = createHeadersWithToken(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
String reposUrl = "https://api.github.com/user/repos";
logger.info("Fetching user repositories from GitHub...");
ResponseEntity<String> response = restTemplate.exchange(
reposUrl, HttpMethod.GET, entity, String.class);
if (!response.getStatusCode().is2xxSuccess()) {
throw new OAuthException("Failed to get user repos: " + response.getStatusCode());
}
GitHubRepo[] repos = objectMapper.readValue(response.getBody(), GitHubRepo[].class);
logger.info("Retrieved {} repositories", repos.length);
return repos;
} catch (Exception e) {
logger.error("Error fetching user repositories", e);
throw new OAuthException("Failed to get user repositories", e);
}
}
private HttpHeaders createHeadersWithToken(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
return headers;
}
}
3. Data Models
// GitHubUser.java
package com.example.githubauth.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
public class GitHubUser {
private Long id;
private String login;
private String name;
private String email;
@JsonProperty("avatar_url")
private String avatarUrl;
@JsonProperty("html_url")
private String htmlUrl;
@JsonProperty("created_at")
private LocalDateTime createdAt;
private String bio;
private String location;
private String company;
@JsonProperty("public_repos")
private Integer publicRepos;
private Integer followers;
private Integer following;
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getAvatarUrl() { return avatarUrl; }
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
public String getHtmlUrl() { return htmlUrl; }
public void setHtmlUrl(String htmlUrl) { this.htmlUrl = htmlUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getCompany() { return company; }
public void setCompany(String company) { this.company = company; }
public Integer getPublicRepos() { return publicRepos; }
public void setPublicRepos(Integer publicRepos) { this.publicRepos = publicRepos; }
public Integer getFollowers() { return followers; }
public void setFollowers(Integer followers) { this.followers = followers; }
public Integer getFollowing() { return following; }
public void setFollowing(Integer following) { this.following = following; }
}
// GitHubEmail.java
package com.example.githubauth.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public class GitHubEmail {
private String email;
private Boolean verified;
private Boolean primary;
@JsonProperty("visibility")
private String visibility;
// Getters and setters
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public Boolean getVerified() { return verified; }
public void setVerified(Boolean verified) { this.verified = verified; }
public Boolean getPrimary() { return primary; }
public void setPrimary(Boolean primary) { this.primary = primary; }
public String getVisibility() { return visibility; }
public void setVisibility(String visibility) { this.visibility = visibility; }
}
// GitHubRepo.java
package com.example.githubauth.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public class GitHubRepo {
private Long id;
private String name;
private String description;
@JsonProperty("html_url")
private String htmlUrl;
@JsonProperty("clone_url")
private String cloneUrl;
private String language;
@JsonProperty("stargazers_count")
private Integer stargazersCount;
@JsonProperty("forks_count")
private Integer forksCount;
@JsonProperty("updated_at")
private String updatedAt;
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getHtmlUrl() { return htmlUrl; }
public void setHtmlUrl(String htmlUrl) { this.htmlUrl = htmlUrl; }
public String getCloneUrl() { return cloneUrl; }
public void setCloneUrl(String cloneUrl) { this.cloneUrl = cloneUrl; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public Integer getStargazersCount() { return stargazersCount; }
public void setStargazersCount(Integer stargazersCount) { this.stargazersCount = stargazersCount; }
public Integer getForksCount() { return forksCount; }
public void setForksCount(Integer forksCount) { this.forksCount = forksCount; }
public String getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; }
}
// OAuthException.java
package com.example.githubauth.model;
public class OAuthException extends Exception {
public OAuthException(String message) {
super(message);
}
public OAuthException(String message, Throwable cause) {
super(message, cause);
}
}
4. Session Management
// SessionService.java
package com.example.githubauth.service;
import com.example.githubauth.model.GitHubUser;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Optional;
@Service
public class SessionService {
private static final String SESSION_USER = "github_user";
private static final String SESSION_ACCESS_TOKEN = "access_token";
public void setUser(GitHubUser user) {
getCurrentSession().ifPresent(session -> {
session.setAttribute(SESSION_USER, user);
});
}
public Optional<GitHubUser> getUser() {
return getCurrentSession()
.map(session -> (GitHubUser) session.getAttribute(SESSION_USER));
}
public void setAccessToken(String accessToken) {
getCurrentSession().ifPresent(session -> {
session.setAttribute(SESSION_ACCESS_TOKEN, accessToken);
});
}
public Optional<String> getAccessToken() {
return getCurrentSession()
.map(session -> (String) session.getAttribute(SESSION_ACCESS_TOKEN));
}
public void invalidate() {
getCurrentSession().ifPresent(HttpSession::invalidate);
}
public boolean isAuthenticated() {
return getUser().isPresent();
}
private Optional<HttpSession> getCurrentSession() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
return Optional.of(request.getSession());
}
return Optional.empty();
}
}
Web Controllers
1. Auth Controller
// AuthController.java
package com.example.githubauth.controller;
import com.example.githubauth.model.*;
import com.example.githubauth.service.GitHubOAuthService;
import com.example.githubauth.service.SessionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;
@Controller
@RequestMapping("/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
private final GitHubOAuthService gitHubOAuthService;
private final SessionService sessionService;
public AuthController(GitHubOAuthService gitHubOAuthService, SessionService sessionService) {
this.gitHubOAuthService = gitHubOAuthService;
this.sessionService = sessionService;
}
/**
* Initiate GitHub OAuth flow
*/
@GetMapping("/github")
public String initiateGitHubAuth() {
String state = generateState();
String authorizationUrl = gitHubOAuthService.getAuthorizationUrl(state);
// Store state in session for validation
sessionService.getCurrentSession().ifPresent(session -> {
session.setAttribute("oauth_state", state);
});
logger.info("Redirecting to GitHub authorization: {}", authorizationUrl);
return "redirect:" + authorizationUrl;
}
/**
* OAuth callback endpoint
*/
@GetMapping("/github/callback")
public String handleGitHubCallback(
@RequestParam("code") String code,
@RequestParam("state") String state,
RedirectAttributes redirectAttributes) {
try {
// Validate state parameter
if (!validateState(state)) {
logger.error("Invalid state parameter: {}", state);
redirectAttributes.addFlashAttribute("error", "Invalid state parameter");
return "redirect:/login";
}
// Exchange code for access token
String accessToken = gitHubOAuthService.exchangeCodeForToken(code);
sessionService.setAccessToken(accessToken);
// Get user information
GitHubUser user = gitHubOAuthService.getUserInfo(accessToken);
// If user email is not public, fetch from emails endpoint
if (user.getEmail() == null) {
GitHubEmail[] emails = gitHubOAuthService.getUserEmails(accessToken);
Optional<GitHubEmail> primaryEmail = findPrimaryEmail(emails);
primaryEmail.ifPresent(email -> user.setEmail(email.getEmail()));
}
sessionService.setUser(user);
logger.info("User {} successfully authenticated via GitHub", user.getLogin());
return "redirect:/dashboard";
} catch (OAuthException e) {
logger.error("OAuth authentication failed", e);
redirectAttributes.addFlashAttribute("error", "Authentication failed: " + e.getMessage());
return "redirect:/login";
} catch (Exception e) {
logger.error("Unexpected error during authentication", e);
redirectAttributes.addFlashAttribute("error", "An unexpected error occurred");
return "redirect:/login";
}
}
/**
* Logout endpoint
*/
@PostMapping("/logout")
public String logout() {
Optional<GitHubUser> user = sessionService.getUser();
user.ifPresent(u -> logger.info("User {} logged out", u.getLogin()));
sessionService.invalidate();
return "redirect:/";
}
private String generateState() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private boolean validateState(String state) {
return sessionService.getCurrentSession()
.map(session -> {
String storedState = (String) session.getAttribute("oauth_state");
session.removeAttribute("oauth_state"); // One-time use
return state.equals(storedState);
})
.orElse(false);
}
private Optional<GitHubEmail> findPrimaryEmail(GitHubEmail[] emails) {
for (GitHubEmail email : emails) {
if (Boolean.TRUE.equals(email.getPrimary()) &&
Boolean.TRUE.equals(email.getVerified())) {
return Optional.of(email);
}
}
return Optional.empty();
}
}
2. Dashboard Controller
// DashboardController.java
package com.example.githubauth.controller;
import com.example.githubauth.model.*;
import com.example.githubauth.service.GitHubOAuthService;
import com.example.githubauth.service.SessionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Optional;
@Controller
@RequestMapping("/dashboard")
public class DashboardController {
private static final Logger logger = LoggerFactory.getLogger(DashboardController.class);
private final SessionService sessionService;
private final GitHubOAuthService gitHubOAuthService;
public DashboardController(SessionService sessionService, GitHubOAuthService gitHubOAuthService) {
this.sessionService = sessionService;
this.gitHubOAuthService = gitHubOAuthService;
}
@GetMapping
public String dashboard(Model model) {
Optional<GitHubUser> userOpt = sessionService.getUser();
if (userOpt.isEmpty()) {
return "redirect:/login";
}
GitHubUser user = userOpt.get();
model.addAttribute("user", user);
try {
// Get user repositories
Optional<String> accessToken = sessionService.getAccessToken();
if (accessToken.isPresent()) {
GitHubRepo[] repos = gitHubOAuthService.getUserRepos(accessToken.get());
model.addAttribute("repositories", repos);
logger.info("Loaded {} repositories for user {}", repos.length, user.getLogin());
}
} catch (OAuthException e) {
logger.error("Failed to load user repositories", e);
model.addAttribute("error", "Failed to load repositories");
}
return "dashboard";
}
@GetMapping("/profile")
public String profile(Model model) {
Optional<GitHubUser> userOpt = sessionService.getUser();
if (userOpt.isEmpty()) {
return "redirect:/login";
}
model.addAttribute("user", userOpt.get());
return "profile";
}
}
3. Home Controller
// HomeController.java
package com.example.githubauth.controller;
import com.example.githubauth.service.SessionService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
private final SessionService sessionService;
public HomeController(SessionService sessionService) {
this.sessionService = sessionService;
}
@GetMapping("/")
public String home(Model model) {
model.addAttribute("authenticated", sessionService.isAuthenticated());
sessionService.getUser().ifPresent(user ->
model.addAttribute("user", user));
return "index";
}
@GetMapping("/login")
public String login() {
if (sessionService.isAuthenticated()) {
return "redirect:/dashboard";
}
return "login";
}
}
Web Security Configuration
// SecurityConfig.java
package com.example.githubauth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/login", "/auth/**", "/css/**", "/js/**", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.logout(logout -> logout
.logoutUrl("/auth/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.csrf(csrf -> csrf
.ignoringRequestMatchers("/auth/github/callback")
);
return http.build();
}
}
HTML Templates
1. Login Page
<!-- src/main/resources/templates/login.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Login - GitHub OAuth App</title> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <style> .login-container { max-width: 400px; margin: 100px auto; padding: 20px; } .github-btn { background-color: #24292e; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer; width: 100%; } .github-btn:hover { background-color: #2ea44f; } </style> </head> <body> <div class="container"> <div class="login-container"> <div class="text-center mb-4"> <h2>Sign In</h2> <p class="text-muted">Choose your authentication method</p> </div> <div th:if="${error}" class="alert alert-danger" role="alert"> <span th:text="${error}"></span> </div> <div class="d-grid gap-2"> <a href="/auth/github" class="github-btn text-decoration-none text-center"> <i class="fab fa-github"></i> Sign in with GitHub </a> </div> <div class="mt-4 text-center"> <small class="text-muted"> By signing in, you agree to our Terms of Service and Privacy Policy </small> </div> </div> </div> </body> </html>
2. Dashboard Page
<!-- src/main/resources/templates/dashboard.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Dashboard - GitHub OAuth App</title> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> </head> <body> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" href="/">GitHub OAuth App</a> <div class="navbar-nav ms-auto"> <a class="nav-link" th:href="@{/dashboard/profile}"> <img th:src="${user.avatarUrl}" width="30" height="30" class="rounded-circle"> <span th:text="${user.login}" class="ms-2"></span> </a> <form th:action="@{/auth/logout}" method="post" class="d-inline"> <button type="submit" class="btn btn-outline-light btn-sm ms-2">Logout</button> </form> </div> </div> </nav> <div class="container mt-4"> <div class="row"> <div class="col-md-4"> <div class="card"> <div class="card-body text-center"> <img th:src="${user.avatarUrl}" width="100" height="100" class="rounded-circle mb-3"> <h4 th:text="${user.name ?: user.login}"></h4> <p class="text-muted" th:text="${user.bio}"></p> <div class="row text-center"> <div class="col"> <strong th:text="${user.publicRepos}"></strong> <div class="text-muted">Repos</div> </div> <div class="col"> <strong th:text="${user.followers}"></strong> <div class="text-muted">Followers</div> </div> <div class="col"> <strong th:text="${user.following}"></strong> <div class="text-muted">Following</div> </div> </div> </div> </div> </div> <div class="col-md-8"> <div class="card"> <div class="card-header"> <h5 class="card-title mb-0">Your Repositories</h5> </div> <div class="card-body"> <div th:if="${error}" class="alert alert-warning" th:text="${error}"></div> <div th:each="repo : ${repositories}" class="border-bottom pb-3 mb-3"> <h6> <a th:href="${repo.htmlUrl}" th:text="${repo.name}" target="_blank"></a> </h6> <p class="text-muted" th:text="${repo.description}"></p> <div class="d-flex gap-3 text-muted small"> <span th:if="${repo.language}" th:text="${repo.language}"></span> <span><i class="fas fa-star"></i> <span th:text="${repo.stargazersCount}"></span></span> <span><i class="fas fa-code-branch"></i> <span th:text="${repo.forksCount}"></span></span> <span>Updated <span th:text="${repo.updatedAt}"></span></span> </div> </div> </div> </div> </div> </div> </div> </body> </html>
Configuration Classes
// AppConfig.java
package com.example.githubauth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
// WebConfig.java
package com.example.githubauth.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
registry.addViewController("/login").setViewName("login");
}
}
Main Application Class
// GitHubOAuthApplication.java
package com.example.githubauth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GitHubOAuthApplication {
public static void main(String[] args) {
SpringApplication.run(GitHubOAuthApplication.class, args);
}
}
Testing
1. Unit Tests
// GitHubOAuthServiceTest.java
package com.example.githubauth.service;
import com.example.githubauth.config.GitHubOAuthProperties;
import com.example.githubauth.model.OAuthException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class GitHubOAuthServiceTest {
@Mock
private RestTemplate restTemplate;
@Mock
private GitHubOAuthProperties properties;
private GitHubOAuthService gitHubOAuthService;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
gitHubOAuthService = new GitHubOAuthService(properties, restTemplate, objectMapper);
when(properties.getAuthorizationUri()).thenReturn("https://github.com/login/oauth/authorize");
when(properties.getClientId()).thenReturn("test-client-id");
when(properties.getRedirectUri()).thenReturn("http://localhost:8080/callback");
}
@Test
void testGetAuthorizationUrl() {
String state = "test-state";
String url = gitHubOAuthService.getAuthorizationUrl(state);
assertNotNull(url);
assertTrue(url.contains("client_id=test-client-id"));
assertTrue(url.contains("state=test-state"));
assertTrue(url.contains("scope=user:email,read:user"));
}
@Test
void testExchangeCodeForToken_Success() throws Exception {
// Setup
when(properties.getTokenUri()).thenReturn("https://github.com/login/oauth/access_token");
when(properties.getClientSecret()).thenReturn("test-secret");
String responseBody = "{\"access_token\":\"test-token\",\"token_type\":\"bearer\"}";
ResponseEntity<String> response = new ResponseEntity<>(responseBody, HttpStatus.OK);
when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
.thenReturn(response);
// Execute
String token = gitHubOAuthService.exchangeCodeForToken("test-code");
// Verify
assertEquals("test-token", token);
}
@Test
void testExchangeCodeForToken_Failure() {
// Setup
when(properties.getTokenUri()).thenReturn("https://github.com/login/oauth/access_token");
when(properties.getClientSecret()).thenReturn("test-secret");
ResponseEntity<String> response = new ResponseEntity<>("Error", HttpStatus.BAD_REQUEST);
when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
.thenReturn(response);
// Execute & Verify
assertThrows(OAuthException.class, () -> {
gitHubOAuthService.exchangeCodeForToken("test-code");
});
}
}
2. Integration Test
// AuthControllerIntegrationTest.java
package com.example.githubauth.controller;
import com.example.githubauth.service.SessionService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(AuthController.class)
class AuthControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SessionService sessionService;
@Test
void testInitiateGitHubAuth() throws Exception {
mockMvc.perform(get("/auth/github"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrlPattern("https://github.com/login/oauth/authorize*"));
}
}
Production Considerations
1. Environment Configuration
# application-prod.yml
github:
oauth:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
redirect-uri: https://yourapp.com/auth/github/callback
server:
port: 443
ssl:
key-store: classpath:keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: tomcat
logging:
level:
com.example.githubauth: INFO
file:
name: /var/log/github-oauth-app.log
2. Error Handling
// GlobalExceptionHandler.java
package com.example.githubauth.controller;
import com.example.githubauth.model.OAuthException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import javax.servlet.http.HttpServletRequest;
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(OAuthException.class)
public String handleOAuthException(OAuthException e, Model model, HttpServletRequest request) {
logger.error("OAuth error for request: {}", request.getRequestURL(), e);
model.addAttribute("error", "Authentication failed: " + e.getMessage());
return "error";
}
@ExceptionHandler(Exception.class)
public String handleGeneralException(Exception e, Model model, HttpServletRequest request) {
logger.error("Unexpected error for request: {}", request.getRequestURL(), e);
model.addAttribute("error", "An unexpected error occurred");
return "error";
}
}
Best Practices
- Security:
- Use state parameter to prevent CSRF
- Store client secrets securely
- Validate all redirects
- Use HTTPS in production
- Error Handling:
- Comprehensive logging
- User-friendly error messages
- Proper exception handling
- Session Management:
- Secure session configuration
- Proper session invalidation
- Session timeout settings
- Performance:
- Cache user data when appropriate
- Optimize API calls
- Use connection pooling
Conclusion
This comprehensive GitHub OAuth implementation provides:
- Secure authentication using OAuth 2.0 authorization code flow
- User profile management with GitHub data
- Repository access and display capabilities
- Production-ready configuration and error handling
- Extensible architecture for additional features
The application can be extended with features like:
- Database persistence for user data
- Additional GitHub API integrations
- Multi-tenant support
- Advanced security features
- Monitoring and metrics