@rineex/authentication-method-passwordless
v0.1.5
Published
Passwordless authentication method for Rineex authentication system
Downloads
720
Maintainers
Readme
@rineex/authentication-method-passwordless
Passwordless authentication method for the Rineex authentication system. Implements a DDD-based challenge-response flow supporting multiple delivery channels (email, SMS, push notifications, authenticator apps).
Overview
What This Component Does
- Manages the lifecycle of passwordless authentication challenges (OTP codes, push nonces, etc.)
- Provides a channel-agnostic architecture for delivering secrets via email, SMS, push, or authenticator apps
- Emits domain events for challenge issuance and verification
- Uses timing-safe secret comparison to prevent timing attacks
When to Use
- Implementing passwordless login flows (magic links, OTP codes)
- Building multi-factor authentication with OTP verification
- Creating authentication systems that don't rely on passwords
When NOT to Use
- Password-based authentication (use a separate auth method)
- Session management (this only handles challenge verification, not session creation)
- Direct integration without implementing required ports (repository, channel, ID generator)
Installation
pnpm add @rineex/authentication-method-passwordlessArchitecture
The package follows Domain-Driven Design (DDD) principles with clear separation between domain, application, and infrastructure layers.
Layer Structure
┌─────────────────────────────────────────────────────────────┐
│ Application Service Layer │
├─────────────────────────────────────────────────────────────┤
│ IssuePasswordlessChallengeService │
│ ───────────────────────────────────── │
│ • Orchestrates aggregate creation │
│ • Coordinates ports (repository, ID generator, clock) │
│ • Returns Result<Aggregate, Error> │
│ │
│ VerifyPasswordlessChallengeService │
│ ───────────────────────────────────── │
│ • Loads challenge from repository │
│ • Delegates verification to aggregate │
│ • Persists state changes │
│ • Returns Result<Aggregate, Error> │
└───────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ PasswordlessChallengeAggregate │
│ (Domain Layer) │
├─────────────────────────────────────────────────────────────┤
│ Properties: │
│ • id: PasswordlessChallengeId │
│ • channel: PasswordlessChannel │
│ • destination: ChallengeDestination │
│ • secret: ChallengeSecret │
│ • status: PasswordlessChallengeStatus │
│ • issuedAt: Date │
│ • expiresAt: Date │
│ │
│ Methods: │
│ • issue() - Factory method, emits IssuedEvent │
│ • verify() - Validates secret, emits VerifiedEvent │
│ • isExpired() - Checks expiration │
│ • matchesSecret() - Timing-safe comparison │
│ • validate() - Enforces invariants │
│ │
│ Domain Events: │
│ • PasswordlessChallengeIssuedEvent │
│ • PasswordlessChallengeVerifiedEvent │
└───────────────────────┬─────────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌───────────────────────┐ ┌──────────────────────────┐
│ Ports (Interfaces) │ │ Value Objects │
├───────────────────────┤ ├──────────────────────────┤
│ • PasswordlessChallenge│ │ • PasswordlessChallengeId│
│ Repository │ │ • PasswordlessChannel │
│ • PasswordlessId │ │ • ChallengeDestination │
│ GeneratorPort │ │ • ChallengeSecret │
│ • ClockPort │ │ • PasswordlessChallenge │
│ • PasswordlessChannel │ │ Status │
│ Port │ │ • OtpCode │
└───────────────────────┘ └──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ (Implementations - Not Included) │
├─────────────────────────────────────────────────────────────┤
│ • Database Repository Implementation │
│ • Clock Adapter (system time, test doubles) │
│ • Channel Adapters: │
│ - Email Channel (SMTP, SendGrid, etc.) │
│ - SMS Channel (Twilio, AWS SNS, etc.) │
│ - Push Notification Channel │
│ - Authenticator App Channel │
└─────────────────────────────────────────────────────────────┘Design Principles
- Port-Adapter Pattern: Domain depends on abstractions (ports), not implementations
- Aggregate Root:
PasswordlessChallengeAggregateencapsulates challenge lifecycle and business rules - Domain Events: State changes emit events for downstream processing
- Result Pattern: Application services return
Result<T, E>for explicit error handling - Value Objects: Immutable, validated domain concepts (Channel, Secret, Destination, etc.)
Flow: Issuing a Challenge
- Application service receives request with channel, destination, and secret
- Service uses
PasswordlessIdGeneratorPortto generate unique challenge ID - Service uses
ClockPortto get current timestamp - Service calls
PasswordlessChallengeAggregate.issue()factory method - Aggregate validates invariants and emits
PasswordlessChallengeIssuedEvent - Service persists aggregate via
PasswordlessChallengeRepository - Service returns
Result.ok(challenge)orResult.fail(error)
Flow: Verifying a Challenge
- Application service receives challenge ID and secret
- Service loads challenge from repository via
findById() - Service checks if challenge exists (returns
Result.failif not found) - Service checks if challenge is expired (returns
Result.failif expired) - Service calls
challenge.verify(secret)on aggregate - Aggregate validates secret using timing-safe comparison
- Aggregate updates status to
verifiedand emitsPasswordlessChallengeVerifiedEvent - Service persists updated aggregate
- Service returns
Result.ok(challenge)orResult.fail(error)
Public API
PasswordlessChallengeAggregate
The aggregate root managing passwordless challenge state and behavior.
Static Methods
| Method | Parameters | Returns | Description |
| --------- | ------------------------- | -------------------------------- | ------------------------------------------------------------------------------- |
| issue() | CreatePasswordlessProps | PasswordlessChallengeAggregate | Creates and issues a new challenge, emitting PasswordlessChallengeIssuedEvent |
Instance Methods
| Method | Parameters | Returns | Description |
| ----------------- | ------------------------------ | --------- | --------------------------------------------------------------- |
| verify() | secret: string, now?: Date | void | Verifies the challenge with provided secret. Throws on failure. |
| isExpired() | now?: Date | boolean | Checks if challenge has expired |
| matchesSecret() | input: string | boolean | Timing-safe secret comparison |
| toObject() | - | object | Returns plain object representation |
| validate() | - | void | Validates aggregate invariants |
Errors Thrown by verify()
| Error | Condition |
| ------------------------------------------ | ------------------------------------- |
| PasswordlessChallengeExpiredError | Challenge has passed expiration time |
| PasswordlessChallengeAlreadyUsedError | Challenge already verified or expired |
| PasswordlessChallengeSecretMismatchError | Provided secret doesn't match |
IssuePasswordlessChallengeService
Application service for issuing new passwordless challenges.
Constructor
constructor(
repository: PasswordlessChallengeRepository,
idGenerator: PasswordlessIdGeneratorPort,
clock: ClockPort
)Methods
| Method | Parameters | Returns | Description |
| ----------- | ---------- | -------------------------------------------------------- | ------------------------------------ |
| execute() | Input | Promise<Result<PasswordlessChallengeAggregate, Error>> | Creates and persists a new challenge |
Input Type
type Input = {
channel: PasswordlessChannel;
destination: ChallengeDestination;
secret: ChallengeSecret;
ttlSeconds?: ms.StringValue; // Default: '300s' (5 minutes)
};VerifyPasswordlessChallengeService
Application service for verifying passwordless challenges.
Constructor
constructor(repository: PasswordlessChallengeRepository)Methods
| Method | Parameters | Returns | Description |
| ----------- | ---------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
| execute() | Input | Promise<Result<PasswordlessChallengeAggregate, Error>> | Verifies a challenge with the provided secret and persists state |
Input Type
type Input = {
id: PasswordlessChallengeId;
secret: ChallengeSecret;
};Errors Returned
| Error | Condition |
| ------------------------------------------ | ------------------------------------ |
| PasswordlessChallengeNotFoundError | Challenge ID not found in repository |
| PasswordlessChallengeExpiredError | Challenge has expired |
| PasswordlessChallengeSecretMismatchError | Provided secret doesn't match |
Any other error thrown during execution (e.g.
PasswordlessChallengeAlreadyUsedError from the aggregate, or repository
rejections) is caught and returned as Result.fail(error).
Value Objects
| Class | Factory Method | Validation |
| ----------------------------- | ------------------------------------- | -------------------------------------------------------------- |
| PasswordlessChannel | create(channel) | Must be 'email', 'sms', 'authenticator_app', or 'push' |
| ChallengeDestination | create(value) | Minimum 3 characters |
| ChallengeSecret | create(value) | Minimum 4 characters |
| OtpCode | create(value) | Exactly 6 digits |
| PasswordlessChallengeId | fromString(id) | Valid domain ID |
| PasswordlessChallengeStatus | issued(), verified(), expired() | Enum-like, closed union |
Domain Events
| Event | Event Name | Payload |
| ------------------------------------ | -------------------------------------- | -------------------------------------- |
| PasswordlessChallengeIssuedEvent | auth.passwordless.challenge_created | { channel, destination, expiresAt } |
| PasswordlessChallengeVerifiedEvent | auth.passwordless.challenge_verified | { channel, destination, verifiedAt } |
Ports (Interfaces to Implement)
PasswordlessChallengeRepository
type PasswordlessChallengeRepository = {
save: (challenge: PasswordlessChallengeAggregate) => Promise<void>;
findById: (id: string) => Promise<PasswordlessChallengeAggregate | null>;
};PasswordlessChannelPort
type PasswordlessChannelPort = {
readonly channelName: PasswordlessChannel;
deliver: (
destination: ChallengeDestination,
secret: ChallengeSecret,
) => Promise<void>;
};PasswordlessIdGeneratorPort
type PasswordlessIdGeneratorPort = {
generate: () => PasswordlessChallengeId;
};PasswordlessChannelRegistry
Runtime registry for channel implementations. Use init() to create; channels
are keyed by channel name.
class PasswordlessChannelRegistry {
static init(
channels: readonly PasswordlessChannelPort[],
): PasswordlessChannelRegistry;
resolve(key: string): PasswordlessChannelPort; // throws if channel not registered
supports(channel: PasswordlessChannel): boolean;
}Usage Examples
Issuing a Challenge
import {
IssuePasswordlessChallengeService,
PasswordlessChannel,
ChallengeDestination,
ChallengeSecret,
} from '@rineex/authentication-method-passwordless';
const service = new IssuePasswordlessChallengeService(
challengeRepository,
idGenerator,
clock,
);
const result = await service.execute({
channel: PasswordlessChannel.create('email'),
destination: ChallengeDestination.create('[email protected]'),
secret: ChallengeSecret.create('123456'),
ttlSeconds: '5m',
});
if (result.isOk()) {
const challenge = result.value;
// Deliver secret via channel
}Verifying a Challenge
Using the application service (recommended):
import {
VerifyPasswordlessChallengeService,
PasswordlessChallengeId,
ChallengeSecret,
} from '@rineex/authentication-method-passwordless';
const verifyService = new VerifyPasswordlessChallengeService(repository);
const result = await verifyService.execute({
id: PasswordlessChallengeId.fromString(challengeId),
secret: ChallengeSecret.create(userProvidedCode),
});
if (result.isOk()) {
const challenge = result.value;
// Challenge verified - create session
} else {
// Handle error (expired, not found, secret mismatch)
const error = result.error;
}Direct aggregate usage (if you need more control):
import {
PasswordlessChallengeExpiredError,
PasswordlessChallengeSecretMismatchError,
PasswordlessChallengeAlreadyUsedError,
} from '@rineex/authentication-method-passwordless';
const challenge = await repository.findById(challengeId);
if (!challenge) {
// Handle not found
return;
}
try {
challenge.verify(userProvidedCode);
// Challenge verified - create session
await repository.save(challenge);
} catch (error) {
if (error instanceof PasswordlessChallengeExpiredError) {
// Handle expired challenge
}
if (error instanceof PasswordlessChallengeAlreadyUsedError) {
// Handle already used challenge
}
if (error instanceof PasswordlessChallengeSecretMismatchError) {
// Handle invalid code
}
}Implementing a Channel
import {
PasswordlessChannelPort,
PasswordlessChannel,
ChallengeDestination,
ChallengeSecret,
} from '@rineex/authentication-method-passwordless';
const emailChannel: PasswordlessChannelPort = {
channelName: PasswordlessChannel.create('email'),
deliver: async (
destination: ChallengeDestination,
secret: ChallengeSecret,
) => {
await emailService.send({
to: destination.value,
subject: 'Your verification code',
body: `Your code is: ${secret.value}`,
});
},
};
channelRegistry.register(emailChannel);Extending Channel Types (TypeScript)
declare module '@rineex/authentication-method-passwordless' {
interface PasswordlessChannelRegistry {
readonly custom_channel: true;
}
}Behavior & Guarantees
Invariants
- A challenge cannot be verified after expiration
- A challenge can only be verified once (single-use)
- Secret comparison is timing-safe (prevents timing attacks)
- Expiration time must be after issuance time
- Channel and secret are required properties
Security Characteristics
- Uses SHA-256 hashing with
crypto.timingSafeEqualfor secret comparison - Secrets are never logged in domain events (only destination and channel)
- Challenges are single-use to prevent replay attacks
Status State Machine
issued ──verify()──> verified (final)
│
└──expires──> expired (final)Operational Notes
Configuration
| Parameter | Default | Description |
| ------------ | -------- | ------------------------------------------------------------------------- |
| ttlSeconds | '300s' | Challenge validity duration (accepts ms format: '5m', '300s', etc.) |
Domain Error Codes
| Code | Type | Description |
| ------------------------------------------ | ---------------------- | -------------------------- |
| AUTH_PASSWORDLESS.CHALLENGE_EXPIRED | DOMAIN.INVALID_STATE | Challenge has expired |
| AUTH_PASSWORDLESS.CHALLENGE_ALREADY_USED | DOMAIN.INVALID_STATE | Challenge already verified |
| AUTH_PASSWORDLESS.CHALLENGE_NOT_FOUND | DOMAIN.INVALID_STATE | Challenge not found |
| AUTH_PASSWORDLESS.SECRET_MISMATCH | DOMAIN.INVALID_VALUE | Secret doesn't match |
| AUTH_PASSWORDLESS.CHANNEL_REQUIRED | DOMAIN.INVALID_VALUE | Missing channel |
| AUTH_PASSWORDLESS.SECRET_REQUIRED | DOMAIN.INVALID_VALUE | Missing secret |
| AUTH_PASSWORDLESS.INVALID_EXPIRATION | DOMAIN.INVALID_VALUE | Invalid expiration time |
Common Pitfalls
- Not persisting after verification: Always save the challenge after calling
verify()to persist the status change - Reusing challenges: Challenges are single-use; issue a new one for each authentication attempt
- Missing channel implementation: Register channel implementations before
use via
PasswordlessChannelRegistry - Clock skew: Use consistent clock implementations across services to prevent premature expiration
Dependencies
@rineex/auth-core: Core authentication abstractions@rineex/ddd: Domain-driven design primitives (AggregateRoot, ValueObject, DomainEvent, Result)ms: Time string parsing
License
Apache-2.0
