GoogleOAuthLinkHelper.java

package com.hwhub.backend.presentation.rest.auth;

import com.hwhub.backend.application.service.oauth.GoogleOAuthService;
import com.hwhub.backend.config.GoogleOAuthProperties;
import com.hwhub.backend.domain.enums.OAuthFlow;
import com.hwhub.backend.security.oauth.OAuthStateSigner;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class GoogleOAuthLinkHelper {

  public static final String STATE_COOKIE_NAME = "hwhub_oauth_state";

  private final GoogleOAuthProperties props;
  private final GoogleOAuthService googleOAuthService;
  private final OAuthStateSigner stateSigner;

  public String generateStateForLink(Long userId) {
    // subject=userId を payload に設定
    return stateSigner.generate(
        OAuthFlow.LINK.getCode(),
        userId.toString(),
        props.getOauthStateSecret(),
        props.getStateTtlSeconds());
  }

  public String generateStateForLogin() {
    // ログイン時にはuserIdが未確定のため空文字を設定
    return stateSigner.generate(
        OAuthFlow.LOGIN.getCode(), "", props.getOauthStateSecret(), props.getStateTtlSeconds());
  }

  public boolean verifyState(String state) {
    return stateSigner.verify(state, props.getOauthStateSecret());
  }

  /** state の cookie 一致 + 署名/TTL 検証 */
  public boolean isValidState(String state, String stateCookie) {
    if (state == null || stateCookie == null) return false;
    if (!state.equals(stateCookie)) return false;
    return verifyState(state);
  }

  public Long extractUserIdFromState(String state) {
    String subject = stateSigner.extractSubject(state);
    if (subject == null || subject.isBlank()) {
      throw new IllegalArgumentException("Invalid state subject");
    }
    try {
      return Long.parseLong(subject);
    } catch (NumberFormatException e) {
      throw new IllegalArgumentException("Invalid state subject: not a number", e);
    }
  }

  public String buildAuthorizationUrl(String state) {
    return googleOAuthService.buildAuthorizationUrl(state);
  }

  public void setStateCookie(HttpServletResponse response, String state) {
    Cookie c = new Cookie(STATE_COOKIE_NAME, state);
    c.setHttpOnly(true);
    c.setSecure(props.isStateCookieSecure());
    c.setPath("/");
    c.setMaxAge((int) Duration.ofSeconds(props.getStateTtlSeconds()).toSeconds());
    c.setAttribute("SameSite", "Lax");
    response.addCookie(c);
  }

  public void clearStateCookie(HttpServletResponse response) {
    Cookie c = new Cookie(STATE_COOKIE_NAME, "");
    c.setHttpOnly(true);
    c.setSecure(props.isStateCookieSecure());
    c.setPath("/");
    c.setMaxAge(0);
    c.setAttribute("SameSite", "Lax");
    response.addCookie(c);
  }

  /** Google連携 成功時:設定画面へ戻す(token付き) */
  public ResponseEntity<Void> redirectToLinkSuccess(String notice, String token) {
    String base = props.getFrontBaseUrl();
    String path = props.getGoogleLinkSuccessRedirectPath();
    String url =
        base
            + "/oauth/result"
            + "?notice="
            + urlEncode(notice)
            + "&token="
            + urlEncode(token)
            + "&next="
            + urlEncode(path);

    return ResponseEntity.status(302).header(HttpHeaders.LOCATION, url).build();
  }

  /** Google連携 失敗時:設定画面へ戻す(reason付き) */
  public ResponseEntity<Void> redirectToLinkFailure(String reason) {
    String base = props.getFrontBaseUrl();
    String path = props.getGoogleLinkFailureRedirectPath();
    String url = base + path + "?notice=googleLinkFailed" + "&reason=" + urlEncode(reason);

    return ResponseEntity.status(302).header(HttpHeaders.LOCATION, url).build();
  }

  /** ログイン成功時:/loginへ戻す(token付き) */
  public ResponseEntity<Void> redirectToLoginSuccess(String notice, String token) {
    String base = props.getFrontBaseUrl();
    String path = props.getSuccessRedirectPath();
    String url = base + path + "?notice=" + urlEncode(notice) + "&token=" + urlEncode(token);
    return ResponseEntity.status(302).header(HttpHeaders.LOCATION, url).build();
  }

  /** ログイン失敗時:/loginへ戻す(reason付き) */
  public ResponseEntity<Void> redirectToLoginFailure(String reason) {
    String base = props.getFrontBaseUrl();
    String path = props.getFailureRedirectPath();
    String url = base + path + "?notice=googleLoginFailed&reason=" + urlEncode(reason);
    return ResponseEntity.status(302).header(HttpHeaders.LOCATION, url).build();
  }

  private String urlEncode(String s) {
    return URLEncoder.encode(s, StandardCharsets.UTF_8);
  }
}