AuthProvider/OAuthProvider/LocalAuthProvider/TokenService via Strategy so multiple auth providers coexist, and session vs token design choices mapped directly onto Spring Security's own internals.
Published September 23, 2026
interface AuthProvider {
AuthResult authenticate(Credentials credentials);
}
class LocalAuthProvider implements AuthProvider {
public AuthResult authenticate(Credentials credentials) {
User user = userRepository.findByEmail(credentials.getEmail());
if (user == null || !passwordEncoder.matches(credentials.getPassword(), user.getPasswordHash())) {
return AuthResult.failure("Invalid credentials");
}
return AuthResult.success(user);
}
}
class OAuthProvider implements AuthProvider {
public AuthResult authenticate(Credentials credentials) {
// exchanges an OAuth authorization code / token with the external provider, then resolves/creates a local User
OAuthTokenResponse tokens = oauthClient.exchangeCode(credentials.getAuthCode());
User user = findOrCreateUserFromOAuthProfile(tokens);
return AuthResult.success(user);
}
}
class AuthenticationService {
private final Map<AuthMethod, AuthProvider> providers; // email/password, Google OAuth, GitHub OAuth, etc.
AuthResult login(AuthMethod method, Credentials credentials) {
AuthProvider provider = providers.get(method);
return provider.authenticate(credentials);
}
}
This is the same Strategy-per-implementation shape as Spring Security's own ProviderManager delegating to multiple AuthenticationProviders (see Authentication Mechanics) — an application supporting both local password login and "Sign in with Google" doesn't branch internally on which method was used; it looks up the right AuthProvider and delegates, exactly matching the real framework's own architecture.
class TokenService {
String issueToken(User user) {
return jwtBuilder.subject(user.getId()).claim("roles", user.getRoles()).expiry(Duration.ofMinutes(15)).sign();
}
Optional<User> validateToken(String token) {
if (!jwtVerifier.isValid(token)) return Optional.empty();
return userRepository.findById(jwtVerifier.getSubject(token));
}
}
This is the direct connection point to production Spring Security, worth stating explicitly:
AuthenticationService.login() create a server-side session (analogous to what SecurityContextHolder's default ThreadLocal-backed context does per-request once populated by the filter chain — see Authentication Mechanics), with a session ID cookie referencing it.TokenService above implements) matches JWT-Based Stateless Auth's custom OncePerRequestFilter directly — validateToken() here is functionally the same operation that filter performs on every incoming request to populate SecurityContextHolder.Recognizing that this LLD exercise's AuthProvider/TokenService classes are a simplified re-derivation of what Spring Security's AuthenticationProvider/JWT filter machinery already does is exactly the kind of LLD-to-framework connection this course has built toward — the goal isn't reinventing Spring Security, it's understanding it well enough to explain why it's built the way it is.
Authentication answers "who are you?". Authorization answers "may you do this, to this thing?". Most real rules have two parts: a permission check ("can edit invoices"), and an ownership or scope check ("…but only invoices in your own organization"). Designing authorization as its own component keeps those rules out of controllers and services:
enum Permission { INVOICE_READ, INVOICE_EDIT, INVOICE_APPROVE, USER_ADMIN }
record Role(String name, Set<Permission> permissions) {}
record Principal(String userId, String orgId, Set<Role> roles) {
boolean has(Permission p) { return roles.stream().anyMatch(r -> r.permissions().contains(p)); }
}
interface Policy<R> {
boolean allows(Principal who, Permission action, R resource);
}
class InvoicePolicy implements Policy<Invoice> {
public boolean allows(Principal who, Permission action, Invoice inv) {
if (!who.has(action)) return false; // RBAC: the role grants the action
if (!inv.orgId().equals(who.orgId())) return false; // tenancy: same organization only
if (action == Permission.INVOICE_APPROVE && inv.createdBy().equals(who.userId()))
return false; // separation of duties
return true;
}
}
class AuthorizationService {
private final Map<Class<?>, Policy<?>> policies;
AuthorizationService(Map<Class<?>, Policy<?>> policies) { this.policies = policies; }
@SuppressWarnings("unchecked")
<R> void check(Principal who, Permission action, R resource) {
Policy<R> policy = (Policy<R>) policies.get(resource.getClass());
if (policy == null || !policy.allows(who, action, resource)) // deny by default
throw new AccessDeniedException(who.userId() + " may not " + action);
}
}
Design principles embedded here:
/invoices/123 from another company.In Spring Security, the same structure maps to @PreAuthorize("hasAuthority('INVOICE_EDIT') and @invoicePolicy.sameOrg(principal, #id)"), or to a custom PermissionEvaluator.
The LocalAuthProvider verifies passwords. How they're stored and how login fails matter as much as the happy path:
class LocalAuthProvider implements AuthProvider {
private final UserRepository users;
private final PasswordEncoder encoder; // BCrypt/Argon2: salted, deliberately slow
private final LoginAttemptTracker attempts;
private final String dummyHash; // precomputed hash of a random value
public AuthResult authenticate(Credentials c) {
if (attempts.isLocked(c.username())) return AuthResult.failed(); // same response as a wrong password
Optional<User> user = users.findByUsername(c.username());
String hash = user.map(User::passwordHash).orElse(dummyHash); // always spend the hashing time
boolean ok = encoder.matches(c.password(), hash) && user.isPresent();
if (!ok) { attempts.recordFailure(c.username()); return AuthResult.failed(); }
attempts.reset(c.username());
return AuthResult.success(user.get());
}
}
Short-lived access tokens (such as 15 minutes) limit the damage from a leaked token, but users shouldn't have to log in every 15 minutes. Pair them with a refresh token: long-lived, stored server-side (or as a hash), and exchanged for a new access token.
jti) until their expiry, or check a per-user "tokens valid after" timestamp.Q: Why does OAuthProvider need to 'find or create' a local User, rather than just trusting the OAuth provider's identity directly? A: Most applications need their own User record (for authorization roles, application-specific data, foreign keys from other tables) regardless of how the user originally authenticated — OAuth establishes IDENTITY, but the application still needs its own representation of that identity to attach permissions and data to, which is exactly why find-or-create-on-first-login is the standard pattern.
Q: How would this design support a user who's authenticated via OAuth wanting to ALSO set a local password later? A: This argues for User supporting multiple linked auth methods (a separate AuthMethod-to-User mapping table, rather than assuming exactly one auth method per user) — a real product decision worth surfacing explicitly rather than assuming the simpler one-auth-method-per-user model always holds.
Q: Should password verification (passwordEncoder.matches()) happen inside LocalAuthProvider or a separate service? A: Keeping it inside LocalAuthProvider (as shown) is reasonable since password matching is specific to that one auth method — a shared AuthenticationService wouldn't have anywhere else meaningful to put OAuth-specific token exchange logic either, so each provider owning its own method-specific verification logic keeps the Strategy separation clean.
Q: Why 15 minutes for token expiry in this example specifically? A: An arbitrary but realistic short-lived access-token duration matching JWT-Based Stateless Auth's own 'short-lived access token, longer-lived refresh token' pattern — worth stating that the SPECIFIC number is a tunable business/security tradeoff (shorter = more secure against token theft, more refresh overhead; longer = the reverse), not a fixed correct value.
Q: What's the difference between RBAC and checking resource ownership? A: RBAC decides whether a user's roles grant an action type in general ("can edit invoices"). Ownership or tenancy checks decide whether that action is allowed on a specific resource ("this invoice belongs to your organization"). Secure systems need both, since RBAC alone lets users act on other people's data.
Q: Why should login failures return the same message whether the username or password was wrong? A: Different messages, or noticeably different response times, reveal which usernames exist, which helps attackers target accounts. A single generic error, and hashing a dummy value for unknown users, removes both signals.
Q: Why rotate refresh tokens? A: With rotation, each refresh token can be used once. If a stolen token is used after the real client has already rotated it (or the reverse), the server sees a reused token, detects the theft, and can revoke the whole session family.