Kind: Service
Source: atloria-monorepo/apps/api/src/reader-account/reader-magic-link.service.ts
C3 magic-link auth — reader-identity claim source 2 ('account').
Mints the SAME docs-reader token as A3/D4 via ReaderTokenService.mintUnion
(never a parallel identity): verify() upserts ReaderAccount + the per-project
ReaderProjectProfile and re-mints with sub + account merged onto whatever
grants/attrs the reader already held.
One-time-code semantics mirror auth/saml/sso-code.service: random 256-bit code, Redis-stored (keyed by its sha256 so a Redis dump leaks nothing redeemable), single-use via atomic GETDEL, short TTL. Abuse control mirrors the A3 grant flows (per-IP/per-email Redis windows) on top of the login throttle family applied at the controller.
ReaderMagicLinkService implements passwordless reader authentication using short-lived, single-use magic-link codes. It verifies a reader’s email, upserts the shared ReaderAccount and project-specific ReaderProjectProfile, then mints the existing union reader token through ReaderTokenService.mintUnion rather than creating a separate identity type.
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
start | start(slugWithId: string, rawEmail: string, ip: string, existingToken: string, returnTo: string) | Promise<{ sent: true }> | |
verify | verify(slugWithId: string, code: string, ip: string, headerToken: string) | Promise<{ token: string; email: string; returnTo: string }> | Single-use redeem → upsert account + project profile → ONE docs-reader token with sub/account UNIONED onto the reader's existing claims. |
sanitizeReturnTo | `sanitizeReturnTo(raw: string | undefined, projectId: string, slugWithId: string)` | Promise<string> |
Dependencies
PrismaServiceRedisServiceReaderTokenServiceDocsAccessServiceEmailService
Where it refuses work
ReaderMagicLinkServicestops the work withNotFoundExceptionwhen!ctx— “Project not found”, in 2 places.ReaderMagicLinkServicestops the work withBadRequestExceptionwhen!ReaderMagicLinkService.EMAIL_RE.test(email)— “Enter a valid email address.”.ReaderMagicLinkServicestops the work withServiceUnavailableExceptionwhen!(await this.redis.exists(key))— “Sign-in is temporarily unavailable — please try again.”.ReaderMagicLinkServicestops the work withUnauthorizedExceptionwhen!payload || payload.projectId !== ctx.projectId.ReaderMagicLinkServicestops the work withHttpExceptionwhenn > max— “Too many attempts — try again shortly.”.ReaderMagicLinkServicestops the work with an early return when!code || typeof code !== 'string' || code.length > 128.
When something fails
ReaderMagicLinkServicehandles failure in 5 places: it turns it into a return value in 3, lets it reach the caller in 1, and discards it silently in 1. A failure discarded silently leaves no trace for whoever debugs this later.
Diagram
mermaidsequenceDiagram participant Client participant Controller participant MagicLink as ReaderMagicLinkService participant Redis participant Email participant Account as ReaderAccount/Profile participant Tokens as ReaderTokenService Client->>Controller: POST /reader-account/magic-link/start Controller->>MagicLink: start(email, project, returnTo, ip) MagicLink->>MagicLink: sanitizeReturnTo(returnTo) MagicLink->>Redis: Check per-IP and per-email rate limits MagicLink->>MagicLink: Generate random 256-bit code MagicLink->>Redis: Store SHA-256(code) with short TTL MagicLink->>Email: Send magic-link URL containing code MagicLink-->>Controller: { sent: true } Controller-->>Client: Confirmation response Client->>Controller: GET/POST magic-link verification with code Controller->>MagicLink: verify(code, project) MagicLink->>Redis: Atomic GETDEL(SHA-256(code)) Redis-->>MagicLink: Stored payload or missing/expired value MagicLink->>Account: Upsert ReaderAccount and ReaderProjectProfile MagicLink->>Tokens: mintUnion(existing grants/attrs + sub + account) Tokens-->>MagicLink: Reader token MagicLink-->>Controller: { token, email, returnTo } Controller-->>Client: Authenticated reader response
Usage
tsimport { Injectable } from '@nestjs/common';
import { ReaderMagicLinkService } from './reader-magic-link.service';
@Injectable()
export class ReaderAuthController {
constructor(
private readonly readerMagicLinkService: ReaderMagicLinkService,
) {}
async requestMagicLink(input: {
email: string;
projectId: string;
returnTo?: string;
ip: string;
}) {
return this.readerMagicLinkService.start();
}
async verifyMagicLink(code: string) {
const { token, email, returnTo } =
await this.readerMagicLinkService.verify();
return {
accessToken: token,
reader: { email },
returnTo,
};
}
}
AI Coding Instructions
- Always mint authenticated reader tokens through
ReaderTokenService.mintUnion; preserve existing reader grants and attributes while addingsubandaccountclaims. - Store only the SHA-256 hash of the generated 256-bit magic-link code in Redis; never persist the redeemable raw code.
- Preserve single-use semantics by consuming codes with an atomic Redis
GETDEL, not separateGETandDELoperations. - Apply both per-IP and per-email Redis rate-limit windows in addition to controller-level login throttling.
- Route user-provided redirect destinations through
sanitizeReturnTo()before storing or returning them to prevent unsafe redirects.
Relationships
- DEPENDS_ON →
PrismaService - DEPENDS_ON →
RedisService - DEPENDS_ON →
ReaderTokenService - DEPENDS_ON →
DocsAccessService - DEPENDS_ON →
EmailService
Referenced By
ReaderAccountController(DEPENDS_ON)ReaderAccountModule(MODULE_PROVIDES)
Was this page helpful?