3 Commits

Author SHA1 Message Date
4b4cc3145f fix frontend
All checks were successful
CI / Maven unit tests (push) Successful in 23s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 20:54:07 +01:00
417e52d261 add tests
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 20:51:56 +01:00
12852e034d add backend tokens
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 17:51:09 +01:00
26 changed files with 1827 additions and 148 deletions

View File

@@ -8,9 +8,9 @@ type AuthContextValue = {
loading: boolean;
/** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. */
/** End the session and clear the user. The local session is cleared even if the server call fails. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). */
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */
refresh: () => Promise<void>;
};

View File

@@ -1,19 +1,66 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts";
import {registerUnauthorizedHandler} from "../utils/api/client.ts";
import {
refreshSession,
registerSessionRefreshedHandler,
registerUnauthorizedHandler,
} from "../utils/api/client.ts";
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
const REFRESH_MARGIN_MS = 60_000;
/** How soon to try again when a scheduled refresh fails because the network is down. */
const RETRY_DELAY_MS = 30_000;
/**
* Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself.
*
* It also keeps the session alive: the access token is refreshed shortly
* before it expires, rather than waiting for a request to fail. That matters
* for <video> elements, which load media straight from the API and can't
* retry on a 401 the way apiFetch does.
*/
const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
const refreshDueRef = useRef<number | null>(null);
const cancelScheduledRefresh = useCallback(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
refreshDueRef.current = null;
}, []);
const endSession = useCallback(() => {
cancelScheduledRefresh();
setUser(null);
}, [cancelScheduledRefresh]);
const scheduleRefreshIn = useCallback((delayMs: number) => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshDueRef.current = Date.now() + delayMs;
refreshTimerRef.current = setTimeout(() => {
refreshSession()
.then((refreshed) => { if (!refreshed) endSession(); })
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
}, delayMs);
}, [endSession]);
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
// reports the new access token lifetime here, so the timer always tracks
// the live cookie.
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
const lifetimeMs = accessTokenExpiresIn * 1000;
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
}, [scheduleRefreshIn]);
const refresh = useCallback(async () => {
try {
setUser(await getUser());
@@ -24,22 +71,50 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
registerUnauthorizedHandler(endSession);
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
}, [endSession, scheduleRefreshBeforeExpiry]);
// Restore the session on page load. Refreshing first means a returning
// user whose access token expired while they were away is logged straight
// back in, and gives us the expiry to schedule the next refresh against.
useEffect(() => {
refreshSession()
.catch((error) => console.error("Failed to refresh session:", error))
.then(refresh)
.finally(() => setLoading(false));
}, [refresh]);
// Timers are throttled in background tabs and paused while the machine
// sleeps, so catch up as soon as the user comes back.
useEffect(() => {
const refreshIfDue = () => {
if (document.visibilityState !== "visible") return;
const due = refreshDueRef.current;
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
};
document.addEventListener("visibilitychange", refreshIfDue);
window.addEventListener("online", refreshIfDue);
return () => {
document.removeEventListener("visibilitychange", refreshIfDue);
window.removeEventListener("online", refreshIfDue);
};
}, [scheduleRefreshIn]);
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
const login = useCallback(async (googleCredential: string) => {
await apiLogin(googleCredential);
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
await refresh();
}, [refresh]);
}, [refresh, scheduleRefreshBeforeExpiry]);
const logout = useCallback(async () => {
// Drop the local session even if the server call fails, so the user
// is never left looking logged in after clicking log out.
endSession();
await apiLogout();
await refresh();
}, [refresh]);
useEffect(() => {
registerUnauthorizedHandler(() => setUser(null));
}, []);
}, [endSession]);
return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>

View File

@@ -1,4 +1,5 @@
import {config} from "../../config.ts";
import type { APIResponse } from "../types.ts";
const API_URL = config.apiUrl;
@@ -7,28 +8,72 @@ class AuthError extends Error {
}
let onUnauthorized: (() => void) | null = null;
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
export function registerUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler;
}
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
onSessionRefreshed = handler;
}
/**
* Performs a request against the API, sending session cookies and funnelling
* expired/missing sessions through the registered unauthorized handler so the
* UI can drop back to a logged out state. Returns the raw response so callers
* that need something other than JSON (blobs, streams) can read it themselves.
* Exchanges the refresh token cookie for new access and refresh token cookies.
* Concurrent callers share a single request, so a burst of 401s only rotates
* the refresh token once.
*
* @returns true if the session was refreshed, false if it has ended (no valid
* refresh token). Throws on network errors so a dropped connection isn't
* mistaken for being logged out.
*/
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
const response = await fetch(url, {
...options,
credentials: 'include'
function refreshSession(): Promise<boolean> {
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
return refreshInFlight;
}
async function requestRefresh(): Promise<boolean> {
const response = await fetch(API_URL + '/api/v1/users/refresh', {
method: 'POST',
credentials: 'include',
});
if (response.status == 401 || response.status == 403) {
if (!response.ok) return false;
const result: APIResponse = await response.json();
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
return true;
}
/**
* Performs a request against the API, sending session cookies. An expired
* access token is refreshed and the request retried once, so callers only see
* an AuthError when the session has really ended — at which point the
* registered unauthorized handler drops the UI back to a logged out state.
* Returns the raw response so callers that need something other than JSON
* (blobs, streams) can read it themselves.
*/
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
const send = () => fetch(url, { ...options, credentials: 'include' });
let response = await send();
if (response.status == 401 && await refreshSession()) {
response = await send();
}
if (response.status == 401) {
onUnauthorized?.();
throw new AuthError();
}
// Logged in, but not allowed to access this resource: don't end the session.
if (response.status == 403) {
throw new AuthError();
}
if (!response.ok) {
const text = await response.text();
let message = text;
@@ -45,4 +90,4 @@ async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
return text ? JSON.parse(text) : (undefined as T);
}
export { API_URL, AuthError, apiFetch, apiFetchRaw };
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };

View File

@@ -1,10 +1,17 @@
import type { APIResponse, User } from "../types.ts";
import { API_URL, apiFetch } from "./client.ts";
import { API_URL } from "./client.ts";
// login and getUser deliberately use fetch directly: they run when there may be
// no session at all, and getUser is what the unauthorized handler calls to
// refresh — routing them through apiFetch would loop on a 401.
const login = async (googleToken: string): Promise<string> => {
// These deliberately use fetch directly rather than apiFetch: they run when
// there may be no session at all, and apiFetch's refresh-and-retry on 401
// would either loop or log the user out from under the auth provider.
/**
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
* cookies; only the access token lifetime is returned.
*
* @returns seconds until the access token expires
*/
const login = async (googleToken: string): Promise<number> => {
const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -17,12 +24,17 @@ const login = async (googleToken: string): Promise<string> => {
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.token;
return result.data.accessTokenExpiresIn;
};
/** Revokes the session and clears the auth cookies. */
const logout = async (): Promise<void> => {
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
};
const getUser = async (): Promise<User | null> => {

View File

@@ -1,13 +1,16 @@
package com.ddf.vodsystem;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableAsync
@EnableConfigurationProperties(AuthProperties.class)
public class VodSystemApplication {
/**

View File

@@ -1,10 +1,12 @@
package com.ddf.vodsystem.configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -24,7 +26,7 @@ public class SecurityConfig {
/**
* Builds the security filter chain: stateless sessions, CSRF disabled, the {@link JwtFilter}
* installed, and per-endpoint authorization rules.
* installed, {@code 401} for unauthenticated requests, and per-endpoint authorization rules.
*
* @param http the HTTP security builder to configure
* @return the built {@link SecurityFilterChain}
@@ -41,11 +43,14 @@ public class SecurityConfig {
.requestMatchers("/api/v1/media/compress").permitAll() // compression available without authenticated
.requestMatchers("/api/v1/jobs/**").permitAll() // same with jobs
.requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/users/me").authenticated()
// authenticated by the refresh token cookie, not the (possibly expired) access token
.requestMatchers("/api/v1/users/login", "/api/v1/users/refresh", "/api/v1/users/logout").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated()
)
// 401 rather than the default 403, so clients know to refresh their session
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);

View File

@@ -0,0 +1,15 @@
package com.ddf.vodsystem.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class TimeConfig {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}

View File

@@ -1,17 +1,21 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.SessionResponse;
import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Optional;
@RestController
@@ -19,12 +23,18 @@ import java.util.Optional;
public class UserController {
private final UserService userService;
@Value("${jwt.expiration}")
private long jwtExpiration;
private final Duration jwtExpiration;
private final Duration refreshTokenExpiration;
private static final String SUCCESS = "success";
public UserController(UserService userService) {
private static final String ACCESS_COOKIE_PATH = "/";
private static final String REFRESH_COOKIE_PATH = "/api/v1/users";
public UserController(UserService userService,
AuthProperties props) {
this.userService = userService;
this.jwtExpiration = props.expiration().jwt();
this.refreshTokenExpiration = props.expiration().refreshToken();
}
/**
@@ -47,59 +57,110 @@ public class UserController {
}
/**
* Authenticates a user from a Google ID token and issues a session JWT.
* Authenticates a user from a Google ID token and starts a session.
* <p>
* The JWT is set as an HttpOnly {@code token} cookie (scoped by request security) and also
* returned in the response body.
* Sets two HttpOnly cookies: {@code accessToken} (the JWT, path {@code /}) and {@code refreshToken}
* (path {@code /api/v1/users}). Neither token is returned in the response body.
*
* @param token the payload carrying the Google ID token to verify
* @param request the incoming request, used to decide cookie {@code Secure}/{@code SameSite} flags
* @param response the response the session cookie is written to
* @return {@code 200 OK} wrapping the issued JWT as a {@link Token}
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the cookies are written to
* @return {@code 200 OK} wrapping the access token lifetime as a {@link SessionResponse}
* @throws NotAuthenticated if the Google ID token is rejected
*/
@PostMapping("/login")
public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true)
.maxAge(jwtExpiration / 1000)
.sameSite(request.isSecure() ? "None" : "Lax")
.secure(request.isSecure())
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
public ResponseEntity<APIResponse<SessionResponse>> login(@RequestBody Token token,
HttpServletRequest request,
HttpServletResponse response) {
TokenPackage tokenPackage = userService.login(token.token());
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
new APIResponse<>(SUCCESS, "Logged in successfully", sessionResponse())
);
}
/**
* Logs the user out by clearing the session cookie.
* Exchanges the {@code refreshToken} cookie for a new refresh token and JWT, replacing both cookies.
*
* @param response the response the expired cookie is written to
* @param request the incoming request, used to match the original cookie's {@code Secure}/{@code SameSite} flags
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the new cookies are written to
* @return {@code 200 OK} wrapping the new access token lifetime as a {@link SessionResponse}
* @throws NotAuthenticated if the refresh token is missing or rejected
*/
@PostMapping("/refresh")
public ResponseEntity<APIResponse<SessionResponse>> refresh(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletRequest request,
HttpServletResponse response) {
TokenPackage tokenPackage = userService.refresh(refreshToken);
addCookies(request, response, tokenPackage.accessToken(), tokenPackage.refreshToken());
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Refreshed tokens successfully", sessionResponse())
);
}
/**
* Logs the user out by revoking the session tied to the {@code refreshToken} cookie and clearing
* both auth cookies. Succeeds even if no refresh token cookie is present.
*
* @param refreshToken the refresh token from the {@code refreshToken} cookie, if present
* @param request the incoming request, used to decide the cookies' {@code Secure} flag
* @param response the response the expired cookies are written to
* @return {@code 200 OK} confirming logout
*/
@PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(HttpServletResponse response,
HttpServletRequest request) {
ResponseCookie cookie = ResponseCookie.from("token", "")
.httpOnly(true)
.maxAge(0)
.sameSite(request.isSecure() ? "None" : "Lax")
.secure(request.isSecure())
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
public ResponseEntity<APIResponse<Void>> logout(
@CookieValue(name = "refreshToken", required = false) String refreshToken,
HttpServletRequest request,
HttpServletResponse response) {
userService.logout(refreshToken);
writeCookies(request, response, "", Duration.ZERO, "", Duration.ZERO);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Logged out successfully", null)
);
}
private SessionResponse sessionResponse() {
return new SessionResponse(jwtExpiration.getSeconds());
}
private void addCookies(
HttpServletRequest request,
HttpServletResponse response,
String accessToken,
String refreshToken) {
writeCookies(request, response, accessToken, jwtExpiration, refreshToken, refreshTokenExpiration);
}
private void writeCookies(
HttpServletRequest request,
HttpServletResponse response,
String accessToken,
Duration accessTokenMaxAge,
String refreshToken,
Duration refreshTokenMaxAge) {
ResponseCookie accessTokenCookie = ResponseCookie.from("accessToken", accessToken)
.httpOnly(true)
.maxAge(accessTokenMaxAge)
.sameSite("Lax")
.secure(request.isSecure())
.path(ACCESS_COOKIE_PATH)
.build();
ResponseCookie refreshTokenCookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.maxAge(refreshTokenMaxAge)
.sameSite("Lax")
.secure(request.isSecure())
.path(REFRESH_COOKIE_PATH)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());
response.addHeader(HttpHeaders.SET_COOKIE, accessTokenCookie.toString());
}
}

View File

@@ -0,0 +1,11 @@
package com.ddf.vodsystem.controllers.dto;
/**
* Session metadata returned after login or refresh. The tokens themselves are only ever sent as
* HttpOnly cookies; this tells the client when to refresh them.
*
* @param accessTokenExpiresIn seconds until the access token cookie expires
*/
public record SessionResponse(
long accessTokenExpiresIn
) {}

View File

@@ -0,0 +1,9 @@
package com.ddf.vodsystem.dto;
public record GoogleUser(
String googleId,
String email,
String name,
String profilePictureUrl
) {
}

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.dto;
public record TokenPackage (
String refreshToken,
String accessToken
) {
}

View File

@@ -0,0 +1,32 @@
package com.ddf.vodsystem.dto.properties;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
@Validated
@ConfigurationProperties(prefix = "app.security")
public record AuthProperties (
@NotBlank @Size(min = 32) String jwtSecret,
@Valid @NotNull Expiration expiration
) {
/**
*
* @param jwt
* @param refreshToken
* @param tokenFamily
* @param gracePeriod
*/
public record Expiration (
@NotNull Duration jwt,
@NotNull Duration refreshToken,
@NotNull Duration tokenFamily,
@NotNull Duration gracePeriod
) {}
}

View File

@@ -0,0 +1,37 @@
package com.ddf.vodsystem.entities;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "refresh_tokens")
@Data
public class RefreshToken {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "family_id", nullable = false)
private TokenFamily tokenFamily;
@Column(name = "token_hash", unique = true, nullable = false, length = 32)
private byte[] tokenHash;
@Column(name = "created_at", nullable = false)
@CreationTimestamp
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "revoked_at")
private Instant revokedAt;
}

View File

@@ -0,0 +1,31 @@
package com.ddf.vodsystem.entities;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
@Entity
@Table(name = "token_families")
@Data
public class TokenFamily {
@Id
@Column(name = "id", nullable = false, unique = true)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "created_at", nullable = false)
@CreationTimestamp
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "revoked_at")
private Instant revokedAt;
}

View File

@@ -0,0 +1,17 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.RefreshToken;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM RefreshToken r WHERE r.tokenHash = ?1")
Optional<RefreshToken> findByHash(byte[] tokenHash);
}

View File

@@ -0,0 +1,30 @@
package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.TokenFamily;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
@Repository
public interface TokenFamilyRepository extends JpaRepository<TokenFamily, Long> {
/**
* Deletes families that can no longer be refreshed: revoked, expired, or with every refresh
* token expired. Their refresh tokens are removed by the {@code ON DELETE CASCADE} foreign key.
*
* @return the number of families deleted
*/
@Modifying
@Query("""
DELETE FROM TokenFamily f
WHERE f.revokedAt IS NOT NULL
OR f.expiresAt <= ?1
OR NOT EXISTS (
SELECT 1 FROM RefreshToken r
WHERE r.tokenFamily = f AND r.expiresAt > ?1
)
""")
int deleteDead(Instant now);
}

View File

@@ -0,0 +1,28 @@
package com.ddf.vodsystem.security;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Collections;
@Configuration
public class GoogleAuthConfig {
/**
* Provides the verifier used to validate Google ID tokens issued for this application's client ID.
*
* @param googleClientId the OAuth client ID that tokens must be issued for
* @return the configured {@link GoogleIdTokenVerifier}
*/
@Bean
public GoogleIdTokenVerifier googleIdTokenVerifier(
@Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId) {
return new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
.setAudience(Collections.singletonList(googleClientId))
.build();
}
}

View File

@@ -0,0 +1,49 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.GoogleUser;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
@Service
public class GoogleVerifierService {
private final GoogleIdTokenVerifier verifier;
public GoogleVerifierService(GoogleIdTokenVerifier verifier) {
this.verifier = verifier;
}
public GoogleUser verify(String idToken) {
GoogleIdToken token;
try {
token = verifier.verify(idToken);
} catch (GeneralSecurityException | IOException e) {
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
}
if (token == null) {
throw new NotAuthenticated("Invalid ID token");
}
String googleId = token.getPayload().getSubject();
if (googleId == null) {
throw new NotAuthenticated("Google ID does not exist");
}
if (!Boolean.TRUE.equals(token.getPayload().getEmailVerified())) {
throw new NotAuthenticated("Email not verified");
}
return new GoogleUser(
googleId,
token.getPayload().getEmail(),
(String) token.getPayload().get("name"),
(String) token.getPayload().get("picture")
);
}
}

View File

@@ -53,7 +53,7 @@ public class JwtFilter extends OncePerRequestFilter {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
jwt = Arrays.stream(cookies)
.filter(cookie -> "token".equals(cookie.getName()))
.filter(cookie -> "accessToken".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);

View File

@@ -7,6 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service;
@@ -20,10 +21,9 @@ public class JwtService {
private static final String USER_ID_CLAIM = "userId";
private static final String ISSUER = "vodsystem";
public JwtService(@Value("${jwt.secret.key}") String jwtSecretKey,
@Value("${jwt.expiration}") long jwtExpiration) {
this.jwtExpiration = jwtExpiration;
this.algorithm = Algorithm.HMAC256(jwtSecretKey);
public JwtService(AuthProperties props) {
this.jwtExpiration = props.expiration().jwt().toMillis();
this.algorithm = Algorithm.HMAC256(props.jwtSecret());
}
/**

View File

@@ -0,0 +1,248 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.RefreshToken;
import com.ddf.vodsystem.entities.TokenFamily;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Optional;
/**
* Issues, rotates and revokes opaque refresh tokens.
* <p>
* Each login starts a {@link TokenFamily}; every refresh token issued for that session belongs to it.
* Only the SHA-256 hash of a token is stored. Presenting an already-used token after the grace
* period is treated as theft and revokes the whole family.
*/
@Service
public class TokenService {
private static final Logger logger = LoggerFactory.getLogger(TokenService.class);
private static final SecureRandom RNG = new SecureRandom();
private static final HexFormat HEX = HexFormat.of();
private final Clock clock;
private final TokenFamilyRepository tokenFamilyRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final Duration tokenFamilyExpiration;
private final Duration refreshTokenExpiration;
private final Duration refreshGracePeriod;
/**
* @param tokenFamilyRepository stores token families (one per login session)
* @param refreshTokenRepository stores hashed refresh tokens
* @param props supplies token family lifetime, refresh token lifetime and reuse grace period
* @param clock time source for all timestamps and expiry checks
*/
public TokenService (
TokenFamilyRepository tokenFamilyRepository,
RefreshTokenRepository refreshTokenRepository,
AuthProperties props,
Clock clock
) {
this.tokenFamilyRepository = tokenFamilyRepository;
this.refreshTokenRepository = refreshTokenRepository;
this.clock = clock;
this.refreshTokenExpiration = props.expiration().refreshToken();
this.tokenFamilyExpiration = props.expiration().tokenFamily();
this.refreshGracePeriod = props.expiration().gracePeriod();
}
/**
* Starts a new session for a user by creating a token family and its first refresh token.
*
* @param user the user the session belongs to
* @return the raw refresh token as a 64-character hex string; only its hash is persisted
*/
@Transactional
public String createSession(User user) {
Instant now = Instant.now(clock);
TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setUser(user);
tokenFamily.setCreatedAt(now);
tokenFamily.setExpiresAt(now.plusMillis(tokenFamilyExpiration.toMillis()));
tokenFamilyRepository.save(tokenFamily);
byte[] token = generateRandomBytes(32);
createRefreshToken(
token,
tokenFamily
);
return HEX.formatHex(token);
}
/**
* Exchanges a refresh token for a new one in the same family.
* <p>
* The presented token is marked used and a new token is issued. Presenting a used token again
* within the grace period returns another new token without extending the grace period. Presenting
* it after the grace period revokes the family; this revocation is committed even though the
* method throws.
*
* @param hexToken the raw refresh token as a hex string
* @return the owning user's ID and the new raw refresh token
* @throws NotAuthenticated if the token is missing, malformed, unknown, expired, belongs to a revoked
* or expired family, or is reused after the grace period
*/
@Transactional(noRollbackFor = NotAuthenticated.class)
public Rotation rotate(String hexToken) {
if (hexToken == null || hexToken.isBlank()) {
throw new NotAuthenticated("Missing refresh token");
}
byte[] hash;
try {
hash = sha256(HEX.parseHex(hexToken));
} catch (IllegalArgumentException e) {
throw new NotAuthenticated("Malformed refresh token");
}
Instant now = Instant.now(clock);
// Check Token is real
RefreshToken refreshToken = refreshTokenRepository.findByHash(hash)
.orElseThrow(() -> new NotAuthenticated("No such refresh token"));
// Find Token Family
TokenFamily tokenFamily = refreshToken.getTokenFamily();
if (tokenFamily.getRevokedAt() != null) {
throw new NotAuthenticated("Token family has been revoked");
}
// Reuse Check
if (refreshToken.getUsedAt() != null && now.isAfter(refreshToken.getUsedAt().plus(refreshGracePeriod))) {
tokenFamily.setRevokedAt(now);
tokenFamilyRepository.save(tokenFamily);
throw new NotAuthenticated("Refresh token already being used");
}
// Expiry Check
if (
now.isAfter(refreshToken.getExpiresAt()) ||
now.isAfter(tokenFamily.getExpiresAt())
) {
throw new NotAuthenticated("Refresh token or family expired");
}
// Invalidate old token, unless we're within the grace period, otherwise the grace gets extended
if (refreshToken.getUsedAt() == null) {
refreshToken.setUsedAt(now);
refreshTokenRepository.save(refreshToken);
}
byte[] newRawToken = generateRandomBytes(32);
createRefreshToken(
newRawToken,
tokenFamily
);
return new Rotation(tokenFamily.getUser().getId(), HEX.formatHex(newRawToken));
}
/**
* Revokes a refresh token and its whole token family, ending the session. Does nothing if the
* token is missing or unknown, and leaves existing revocation timestamps unchanged.
*
* @param hexToken the raw refresh token as a hex string, may be {@code null}
* @throws NotAuthenticated if the token is not valid hex
*/
@Transactional
public void revoke(String hexToken) {
if (hexToken == null || hexToken.isBlank()) return;
byte[] hash;
try {
hash = sha256(HEX.parseHex(hexToken));
} catch (IllegalArgumentException e) {
throw new NotAuthenticated("Malformed refresh token");
}
Optional<RefreshToken> found = refreshTokenRepository.findByHash(hash);
if (found.isEmpty()) return;
Instant now = Instant.now(clock);
RefreshToken refreshToken = found.get();
TokenFamily family = refreshToken.getTokenFamily();
if (family.getRevokedAt() == null) {
family.setRevokedAt(now);
tokenFamilyRepository.save(family);
}
if (refreshToken.getRevokedAt() == null) {
refreshToken.setRevokedAt(now);
refreshTokenRepository.save(refreshToken);
}
}
/**
* Scheduled cleanup that deletes token families that can no longer be refreshed, along with all
* their refresh tokens.
* <p>
* Runs hourly. Used tokens in live families are kept, since reuse detection needs them.
*/
@Scheduled(fixedDelay = 3_600_000)
@Transactional
public void deleteDeadSessions() {
int deleted = tokenFamilyRepository.deleteDead(Instant.now(clock));
if (deleted > 0) logger.info("Deleted {} dead token families", deleted);
}
/**
* Result of a successful {@link #rotate(String)}.
*
* @param userId ID of the user who owns the token family
* @param newRefreshToken the newly issued raw refresh token as a hex string
*/
public record Rotation(Long userId, String newRefreshToken) {}
private static byte[] sha256(byte[] in) {
try {
return MessageDigest.getInstance("SHA-256").digest(in);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private byte[] generateRandomBytes(int length) {
byte[] bytes = new byte[length];
RNG.nextBytes(bytes);
return bytes;
}
private RefreshToken createRefreshToken(
byte[] token,
TokenFamily tokenFamily
) {
Instant now = Instant.now(clock);
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setTokenHash(sha256(token));
refreshToken.setCreatedAt(now);
refreshToken.setExpiresAt(now.plusMillis(refreshTokenExpiration.toMillis()));
return refreshTokenRepository.save(refreshToken);
}
}

View File

@@ -1,47 +1,44 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.GoogleUser;
import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository;
import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import org.springframework.beans.factory.annotation.Value;
import com.ddf.vodsystem.security.TokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Collections;
import java.util.HexFormat;
import java.util.Optional;
@Service
public class UserService {
private final GoogleIdTokenVerifier verifier;
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
private final GoogleVerifierService googleVerifierService;
private final UserRepository userRepository;
private final JwtService jwtService;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final TokenService tokenService;
public UserService(UserRepository userRepository,
JwtService jwtService,
@Value("${google.client.id}") String googleClientId) {
GoogleVerifierService googleVerifierService,
TokenService tokenService) {
this.userRepository = userRepository;
NetHttpTransport transport = new NetHttpTransport();
JsonFactory jsonFactory = new GsonFactory();
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
.setAudience(Collections.singletonList(googleClientId))
.build();
this.googleVerifierService = googleVerifierService;
this.jwtService = jwtService;
this.tokenService = tokenService;
}
/**
@@ -68,24 +65,58 @@ public class UserService {
}
/**
* Verifies a Google ID token, creating or updating the corresponding user, and issues a session JWT.
* Verifies a Google ID token, creates or updates the matching user, and starts a new session.
* <p>
* A new user gets a username equal to their email, the default role and a random stream key.
* Email, name and profile picture are refreshed from Google on every login.
*
* @param idToken the Google ID token to verify
* @return a signed JWT for the authenticated user
* @throws NotAuthenticated if the token is invalid or its subject is missing
* @return a new refresh token and a signed JWT access token for the user
* @throws NotAuthenticated if the ID token is invalid, has no subject, or its email is unverified
*/
public String login(String idToken) {
GoogleIdToken googleIdToken = getGoogleIdToken(idToken);
String googleId = googleIdToken.getPayload().getSubject();
@Transactional
public TokenPackage login(String idToken) {
GoogleUser googleUser = googleVerifierService.verify(idToken);
User user = createOrUpdateGoogleUser(googleUser);
if (googleId == null) {
throw new NotAuthenticated("Invalid ID token");
String refreshToken = tokenService.createSession(user);
String jwt = jwtService.generateToken(user.getId());
logger.debug("Access and Refresh tokens issued for User ID {}", user.getId());
return new TokenPackage(refreshToken, jwt);
}
/**
* Rotates a refresh token and issues a new JWT access token for its owner.
*
* @param refreshToken the raw refresh token as a hex string
* @return the new refresh token and a signed JWT access token
* @throws NotAuthenticated if the refresh token is rejected; see {@link TokenService#rotate(String)}
*/
public TokenPackage refresh(String refreshToken) {
TokenService.Rotation rotation = tokenService.rotate(refreshToken);
String jwt = jwtService.generateToken(rotation.userId());
logger.debug("New refresh and access token issued for user ID {}", rotation.userId());
return new TokenPackage(rotation.newRefreshToken(), jwt);
}
/**
* Ends the session tied to a refresh token by revoking its token family.
* Already-issued JWT access tokens stay valid until they expire.
*
* Does nothing if the token is missing, malformed or unknown.
*
* @param refreshToken the raw refresh token as a hex string, may be {@code null}
*/
public void logout(String refreshToken) {
try {
tokenService.revoke(refreshToken);
} catch (NotAuthenticated e) {
logger.info("Malformed or missing token on logout()");
}
User googleUser = getGoogleUser(googleIdToken);
User user = createOrUpdateUser(googleUser);
return jwtService.generateToken(user.getId());
}
/**
@@ -98,51 +129,30 @@ public class UserService {
return userRepository.findByStreamKey(streamKey);
}
private User createOrUpdateUser(User user) {
Optional<User> existingUser = userRepository.findByGoogleId(user.getGoogleId());
private User createOrUpdateGoogleUser(GoogleUser googleUser) {
Optional<User> existingUser = userRepository.findByGoogleId(googleUser.googleId());
User user;
if (existingUser.isEmpty()) {
user = new User();
user.setGoogleId(googleUser.googleId());
user.setUsername(googleUser.email());
user.setRole(0);
user.setCreatedAt(Instant.now());
user.setStreamKey(generateStreamKey());
return userRepository.saveAndFlush(user);
user.setStreamKey(HexFormat.of().formatHex(generateRandomBytes(24)));
} else {
user = existingUser.get();
}
User existing = existingUser.get();
existing.setEmail(user.getEmail());
existing.setName(user.getName());
existing.setProfilePictureUrl(user.getProfilePictureUrl());
existing.setUsername(user.getUsername());
return userRepository.saveAndFlush(existing);
user.setEmail(googleUser.email());
user.setName(googleUser.name());
user.setProfilePictureUrl(googleUser.profilePictureUrl());
return userRepository.save(user);
}
private User getGoogleUser(GoogleIdToken idToken) {
String googleId = idToken.getPayload().getSubject();
String email = idToken.getPayload().getEmail();
String name = (String) idToken.getPayload().get("name");
String profilePictureUrl = (String) idToken.getPayload().get("picture");
User user = new User();
user.setGoogleId(googleId);
user.setEmail(email);
user.setName(name);
user.setUsername(email);
user.setProfilePictureUrl(profilePictureUrl);
return user;
}
private GoogleIdToken getGoogleIdToken(String idToken) {
try {
return verifier.verify(idToken);
} catch (GeneralSecurityException | IOException e) {
throw new NotAuthenticated("Invalid ID token: " + e.getMessage());
}
}
private String generateStreamKey() {
byte[] bytes = new byte[24];
private byte[] generateRandomBytes(int length) {
byte[] bytes = new byte[length];
SECURE_RANDOM.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
return bytes;
}
}

View File

@@ -20,8 +20,12 @@ spring.flyway.locations=classpath:db/migrations
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email
jwt.secret.key=${JWT_SECRET_KEY}
jwt.expiration=3600000
app.security.jwt-secret=${JWT_SECRET_KEY}
app.security.expiration.jwt=5m
app.security.expiration.refresh-token=7d
app.security.expiration.token-family=90d
app.security.expiration.grace-period=5s
# Frontend
frontend.url=${FRONTEND_URL}

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS token_families
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_token_families_user_id
ON token_families (user_id);
CREATE TABLE IF NOT EXISTS refresh_tokens
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES token_families (id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE, -- unique automatically creates a B-tree index
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
CONSTRAINT refresh_tokens_hash_len CHECK (octet_length(token_hash) = 32)
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family_id
ON refresh_tokens (family_id);

View File

@@ -0,0 +1,628 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.RefreshToken;
import com.ddf.vodsystem.entities.TokenFamily;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.security.MessageDigest;
import java.time.*;
import java.util.HexFormat;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MutableClock extends Clock {
private Instant now;
MutableClock(Instant start) {
this.now = start;
}
void advance(Duration d) {
now = now.plus(d);
}
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
}
@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
@Mock private TokenFamilyRepository tokenFamilyRepository;
@Mock private RefreshTokenRepository refreshTokenRepository;
@Captor ArgumentCaptor<TokenFamily> familyCaptor;
@Captor ArgumentCaptor<RefreshToken> tokenCaptor;
private static final HexFormat HEX = HexFormat.of();
private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef";
AuthProperties authProperties;
TokenService tokenService;
MutableClock clock;
User createUser(Long id, String username, String email) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setEmail(email);
return user;
}
@BeforeEach
void setUp() {
authProperties = new AuthProperties(
"a-secret",
new AuthProperties.Expiration(
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(5)
)
);
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
tokenService = new TokenService(
tokenFamilyRepository,
refreshTokenRepository,
authProperties,
clock
);
}
@Nested
@DisplayName("createSession()")
class createSession {
@Test
void createSession_createsTokenFamily() {
User user = createUser(1L, "user", "user@gmail.com");
tokenService.createSession(user);
verify(tokenFamilyRepository).save(familyCaptor.capture());
verify(refreshTokenRepository).save(tokenCaptor.capture());
TokenFamily tokenFamily = familyCaptor.getValue();
RefreshToken refreshToken = tokenCaptor.getValue();
assertSame(tokenFamily.getUser(), user);
assertSame(refreshToken.getTokenFamily(), tokenFamily);
assertEquals(
tokenFamily.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis())
);
}
@Test
void createSession_savesRefreshTokenCreatedAtAndExpires() {
User user = createUser(1L, "user", "user");
tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertEquals(refreshToken.getCreatedAt(), clock.instant());
assertEquals(
refreshToken.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis())
);
}
@Test
void createSession_returnsHexTokenOf64Characters() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
assertEquals(64, token.length());
assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token);
}
@Test
void createSession_doesNotStoreRawToken() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token));
}
}
// Rotate
@Nested
@DisplayName("rotate()")
class rotate {
// Input val
@Test
void nullToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(null)
);
}
@Test
void blankToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate("")
);
}
// Invalid cases
@Test
void unknownToken_throwsNotAuthenticated() {
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.empty()
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
@Test
void revokedFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(Instant.now(clock));
RefreshToken refreshToken = tokenIn(tokenFamily);
clock.advance(Duration.ofMinutes(1));
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.of(refreshToken)
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
// Happy Path
@Test
void unusedToken_returnsUserIdAndRefreshToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
}
@Test
void unsedToken_savesRefreshToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() == null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("no unused token was saved"));
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner
assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one
assertNull(newToken.getUsedAt());
}
@Test
void ununsedToken_savesOldTokenWithUsedAt() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken savedToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() != null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("used_at field not set in any token"));
assertEquals(savedToken.getUsedAt(), clock.instant());
}
@Test
void unusedToken_doesNotRevokeFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() == null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("no unused token was saved"));
assertNull(newToken.getTokenFamily().getRevokedAt());
}
@Test
void unusedToken_newTokenIsDifferentFromOldToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertNotEquals(rawToken, rotation.newRefreshToken());
}
// Grace period checks
@Test
void usedWithinGrace_returnsNewToken() {
Instant usedAt = Instant.now(clock);
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
refreshToken.setUsedAt(usedAt);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
// within grace: 3s since usedAt, grace is 5s, and nothing has expired yet
clock.advance(Duration.ofSeconds(3));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
assertNull(tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
// grace is not extended by a replay, and only the new token is written
assertEquals(usedAt, refreshToken.getUsedAt());
verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getValue();
assertNull(newToken.getUsedAt());
assertSame(tokenFamily, newToken.getTokenFamily());
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
}
@Test
void usedAtExactGraceBoundary_returnsNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(authProperties.expiration().gracePeriod());
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNull(tokenFamily.getRevokedAt());
}
// Reuse after grace period
@Test
void usedAfterGrace_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void usedAfterGrace_revokesFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(tokenFamilyRepository).save(familyCaptor.capture());
assertSame(tokenFamily, familyCaptor.getValue());
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
}
@Test
void usedAfterGrace_doesNotIssueNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
}
@Test
void usedAfterGrace_subsequentRotateRejectedWithoutResaving() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(tokenFamilyRepository, times(1)).save(any());
}
// Expiry
@Test
void expiredToken_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setExpiresAt(clock.instant().minusSeconds(1));
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setExpiresAt(clock.instant().minusSeconds(1));
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredToken_doesNotSaveOrRevoke() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
verify(tokenFamilyRepository, never()).save(any());
assertNull(refreshToken.getUsedAt());
assertNull(tokenFamily.getRevokedAt());
}
@Test
void tokenAtExactExpiry_returnsNewToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(10));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
}
// Ordering
@Test
void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() {
TokenFamily tokenFamily = activeFamily();
Instant revokedAt = clock.instant();
tokenFamily.setRevokedAt(revokedAt);
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(6));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertEquals(revokedAt, tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
}
@Test
void revokedAndExpiredFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(clock.instant());
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
}
@Test
void expiredAndUsedAfterGrace_revokesFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(11));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
verify(tokenFamilyRepository).save(tokenFamily);
}
@Test
void expiredWithinGrace_doesNotRevokeFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setExpiresAt(clock.instant().plusSeconds(2));
refreshToken.setUsedAt(clock.instant());
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofSeconds(3));
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
assertNull(tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
verify(refreshTokenRepository, never()).save(any());
}
@Test
void unknownToken_doesNotTouchRepositoriesFurther() {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken));
verify(refreshTokenRepository, never()).save(any());
verifyNoInteractions(tokenFamilyRepository);
}
}
@Nested
@DisplayName("revoke()")
class revoke {
@Test
void knownToken_revokesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
assertEquals(clock.instant(), refreshToken.getRevokedAt());
}
@Test
void knownToken_savesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
verify(tokenFamilyRepository).save(tokenFamily);
verify(refreshTokenRepository).save(refreshToken);
}
@Test
void knownToken_looksUpBySingleSha256Hash() throws Exception {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken));
verify(refreshTokenRepository).findByHash(expectedHash);
}
@Test
void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() {
Instant revokedAt = clock.instant();
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(revokedAt);
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setRevokedAt(revokedAt);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofMinutes(1));
tokenService.revoke(rawToken);
assertEquals(revokedAt, tokenFamily.getRevokedAt());
assertEquals(revokedAt, refreshToken.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
verify(refreshTokenRepository, never()).save(any());
}
@Test
void unknownToken_doesNothing() {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
verify(refreshTokenRepository, never()).save(any());
verifyNoInteractions(tokenFamilyRepository);
}
@Test
void nullToken_doesNothing() {
tokenService.revoke(null);
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void blankToken_doesNothing() {
tokenService.revoke("");
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void malformedToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.revoke("not-hex")
);
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
}
private TokenFamily activeFamily() {
TokenFamily tokenFamily = new TokenFamily();
tokenFamily.setId(1L);
tokenFamily.setUser(createUser(1L, "user", "user"));
tokenFamily.setExpiresAt(clock.instant().plusSeconds(10));
return tokenFamily;
}
private RefreshToken tokenIn(TokenFamily tokenFamily) {
RefreshToken refreshToken = new RefreshToken();
refreshToken.setTokenFamily(tokenFamily);
refreshToken.setExpiresAt(clock.instant().plusSeconds(10));
return refreshToken;
}
}

View File

@@ -0,0 +1,295 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.GoogleUser;
import com.ddf.vodsystem.dto.TokenPackage;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.UserRepository;
import com.ddf.vodsystem.security.GoogleVerifierService;
import com.ddf.vodsystem.security.JwtService;
import com.ddf.vodsystem.security.TokenService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private GoogleVerifierService googleVerifierService;
@Mock private JwtService jwtService;
@Mock private UserRepository userRepository;
@Mock private TokenService tokenService;
@Captor ArgumentCaptor<User> userCaptor;
private static final String ID_TOKEN = "google-id-token";
private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef";
private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe";
private static final String JWT = "header.payload.signature";
private static final String GOOGLE_ID = "google-id";
UserService userService;
User createUser(Long id, String username, String email) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setEmail(email);
return user;
}
GoogleUser googleUser() {
return new GoogleUser(
GOOGLE_ID,
"user@example.com",
"My User",
"https://lol.com/1"
);
}
@BeforeEach
void setUp() {
userService = new UserService(
userRepository,
jwtService,
googleVerifierService,
tokenService
);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Nested
@DisplayName("getUserById()")
class getUserById {
@Test
void existingId_returnsUser() {
User user = createUser(1L, "user", "user@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
Optional<User> result = userService.getUserById(1L);
assertTrue(result.isPresent());
assertSame(user, result.get());
}
@Test
void unknownId_returnsEmpty() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertTrue(userService.getUserById(1L).isEmpty());
}
}
@Nested
@DisplayName("getLoggedInUser()")
class getLoggedInUser {
@Test
void authenticatedUserPrincipal_returnsUser() {
User user = createUser(1L, "user", "user@example.com");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null, List.of())
);
Optional<User> result = userService.getLoggedInUser();
assertTrue(result.isPresent());
assertSame(user, result.get());
}
@Test
void noAuthentication_returnsEmpty() {
assertTrue(userService.getLoggedInUser().isEmpty());
}
@Test
void unauthenticated_returnsEmpty() {
User user = createUser(1L, "user", "user@example.com");
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user, null)
);
assertTrue(userService.getLoggedInUser().isEmpty());
}
@Test
void nonUserPrincipal_returnsEmpty() {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("anonymous", null, List.of())
);
assertTrue(userService.getLoggedInUser().isEmpty());
}
}
@Nested
@DisplayName("login()")
class login {
@Test
void newUser_savesUserToRepo() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertEquals(GOOGLE_ID, saved.getGoogleId());
assertEquals("user@example.com", saved.getEmail());
assertEquals("user@example.com", saved.getUsername());
assertEquals("My User", saved.getName());
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
assertEquals(0, saved.getRole());
assertNotNull(saved.getCreatedAt());
}
@Test
void newUser_generatesHexStreamKeyOf48Characters() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
String streamKey = userCaptor.getValue().getStreamKey();
assertEquals(48, streamKey.length());
assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey);
}
@Test
void existingUser_updatesUserInfo() {
User existingUser = createUser(1L, "old-username", "old@example.com");
existingUser.setGoogleId(GOOGLE_ID);
existingUser.setName("Old Name");
existingUser.setProfilePictureUrl("https://lol.com/old");
existingUser.setStreamKey("123");
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
userService.login(ID_TOKEN);
verify(userRepository).save(userCaptor.capture());
User saved = userCaptor.getValue();
assertSame(existingUser, saved);
assertEquals(1L, saved.getId());
assertEquals(GOOGLE_ID, saved.getGoogleId());
assertEquals("user@example.com", saved.getEmail());
assertEquals("My User", saved.getName());
assertEquals("https://lol.com/1", saved.getProfilePictureUrl());
assertEquals("old-username", saved.getUsername());
assertEquals("123", saved.getStreamKey());
}
@Test
void validToken_returnsRefreshTokenAndJwt() {
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN);
when(jwtService.generateToken(any())).thenReturn(JWT);
TokenPackage tokenPackage = userService.login(ID_TOKEN);
assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken());
assertEquals(JWT, tokenPackage.accessToken());
}
@Test
void validToken_createsSessionForSavedUser() {
User savedUser = createUser(42L, "user@example.com", "user@example.com");
when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser());
when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty());
when(userRepository.save(any())).thenReturn(savedUser);
userService.login(ID_TOKEN);
verify(tokenService).createSession(savedUser);
verify(jwtService).generateToken(42L);
}
@Test
void invalidToken_throwsNotAuthenticated() {
when(googleVerifierService.verify(ID_TOKEN))
.thenThrow(new NotAuthenticated("Invalid ID token"));
assertThrows(
NotAuthenticated.class,
() -> userService.login(ID_TOKEN)
);
verifyNoInteractions(userRepository, tokenService, jwtService);
}
}
@Nested
@DisplayName("refresh()")
class refresh {
@Test
void validToken_returnsRotatedTokenAndJwt() {
when(tokenService.rotate(REFRESH_TOKEN))
.thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN));
when(jwtService.generateToken(1L)).thenReturn(JWT);
TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN);
assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken());
assertEquals(JWT, tokenPackage.accessToken());
}
@Test
void invalidToken_throwsNotAuthenticated() {
when(tokenService.rotate(REFRESH_TOKEN))
.thenThrow(new NotAuthenticated("Invalid refresh token"));
assertThrows(
NotAuthenticated.class,
() -> userService.refresh(REFRESH_TOKEN)
);
verifyNoInteractions(jwtService);
}
}
@Nested
@DisplayName("logout()")
class logout {
@Test
void revokesRefreshToken() {
userService.logout(REFRESH_TOKEN);
verify(tokenService).revoke(REFRESH_TOKEN);
}
@Test
void doesNotIssueTokens() {
userService.logout(REFRESH_TOKEN);
verify(tokenService, never()).createSession(any());
verify(tokenService, never()).rotate(any());
verifyNoInteractions(jwtService);
}
}
}