Skip to content

Auth: request-link and verify flows - #46

Open
RandyJDean wants to merge 1 commit into
07-09-auth_core_building_blocks_token_generation_repositories_clockfrom
07-09-auth_request-link_and_verify_flows
Open

Auth: request-link and verify flows#46
RandyJDean wants to merge 1 commit into
07-09-auth_core_building_blocks_token_generation_repositories_clockfrom
07-09-auth_request-link_and_verify_flows

Conversation

@RandyJDean

Copy link
Copy Markdown
Contributor
  • AuthService.requestLink: normalize email, Bucket4j rate limits
    (3/email + 10/IP per 15 min) enforced silently, invalidate prior
    tokens, email a 15-minute single-use link via the EmailSender port
    (dev profile logs the link instead of sending)
  • AuthService.verify: atomic UPDATE..RETURNING consume, shell member
    creation on first login (ON CONFLICT DO NOTHING + re-select)
  • Generic response contract: request-link always answers the same way,
    so account existence and rate limiting are unobservable
  • InvalidMagicLinkException -> 400 failure envelope

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

RandyJDean commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@graphite-app

graphite-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

Graphite Automations

"Request reviewers once CI passes" took an action on this PR • (07/10/26)

2 reviewers were added to this PR based on Henry Chen's automation.

import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.email.EmailSender;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should put this dependency behind a client and have a standardized way of calling it.

Otherwise spaghetti will ensue with dependencies.

final String email = normalize(rawEmail);
if (!rateLimiter.tryAcquire(email, clientIp)) {
log.warn("Rate-limited magic-link request for {} from {}", email, clientIp);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#49 (comment) related comment on your docs PR that I think could be a better approach here.

@RandyJDean
RandyJDean force-pushed the 07-09-auth_core_building_blocks_token_generation_repositories_clock branch from e49fcf1 to 789997b Compare July 31, 2026 17:24
@RandyJDean
RandyJDean force-pushed the 07-09-auth_request-link_and_verify_flows branch from 9ebd548 to 2a9e88a Compare July 31, 2026 17:24
Comment on lines +27 to +31
public boolean tryAcquire(final String email, final String clientIp) {
final boolean emailAllowed = emailBuckets.getUnchecked(email).tryConsume(1);
final boolean ipAllowed = ipBuckets.getUnchecked(clientIp).tryConsume(1);
return emailAllowed && ipAllowed;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical rate limiting bug: When emailAllowed is true but ipAllowed is false, the email bucket has already consumed a token but the method returns false (request denied). This means the email loses a token from their budget even though the request was blocked by IP rate limiting.

Example: Email has 3 tokens left, IP has 0 tokens. Call tryAcquire() → email consumes 1 (now 2 left) → IP fails → returns false → email unfairly lost a token.

Fix by checking both buckets before consuming:

public boolean tryAcquire(final String email, final String clientIp) {
    final Bucket emailBucket = emailBuckets.getUnchecked(email);
    final Bucket ipBucket = ipBuckets.getUnchecked(clientIp);
    
    if (emailBucket.tryConsume(1)) {
        if (ipBucket.tryConsume(1)) {
            return true;
        }
        // Rollback: refill the email bucket since IP failed
        emailBucket.addTokens(1);
    }
    return false;
}
Suggested change
public boolean tryAcquire(final String email, final String clientIp) {
final boolean emailAllowed = emailBuckets.getUnchecked(email).tryConsume(1);
final boolean ipAllowed = ipBuckets.getUnchecked(clientIp).tryConsume(1);
return emailAllowed && ipAllowed;
}
public boolean tryAcquire(final String email, final String clientIp) {
final Bucket emailBucket = emailBuckets.getUnchecked(email);
final Bucket ipBucket = ipBuckets.getUnchecked(clientIp);
if (emailBucket.tryConsume(1)) {
if (ipBucket.tryConsume(1)) {
return true;
}
// Rollback: refill the email bucket since IP failed
emailBucket.addTokens(1);
}
return false;
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

- AuthService.requestLink: normalize email, Bucket4j rate limits
  (3/email + 10/IP per 15 min) enforced silently, invalidate prior
  tokens, email a 15-minute single-use link via the EmailSender port
  (dev profile logs the link instead of sending)
- AuthService.verify: atomic UPDATE..RETURNING consume, shell member
  creation on first login (ON CONFLICT DO NOTHING + re-select)
- Generic response contract: request-link always answers the same way,
  so account existence and rate limiting are unobservable
- InvalidMagicLinkException -> 400 failure envelope

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@RandyJDean
RandyJDean force-pushed the 07-09-auth_core_building_blocks_token_generation_repositories_clock branch from 789997b to 71f627d Compare July 31, 2026 18:17
@RandyJDean
RandyJDean force-pushed the 07-09-auth_request-link_and_verify_flows branch from 2a9e88a to 04a095e Compare July 31, 2026 18:17
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants