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).
- 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
- Implementing passwordless login flows (magic links, OTP codes)
- Building multi-factor authentication with OTP verification
- Creating authentication systems that don't rely on passwords
- 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)
pnpm add @rineex/authentication-method-passwordlessThe package follows Domain-Driven Design (DDD) principles with clear separation between domain, application, and infrastructure layers.
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
- 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.)
- 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.err(error)
- Application service receives challenge ID and secret
- Service loads challenge from repository via
findById() - Service checks if challenge exists (returns
Result.errif not found) - Service checks if challenge is expired (returns
Result.errif 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.err(error)
Export gap: The runtime API described below exists in source but is not exported from
src/index.tstoday — the package entry only loads type augmentation forAuthMethodRegistry. Import from source paths in the monorepo or wait until exports are added (see auth-core GAP_ANALYSIS.md).
The aggregate root managing passwordless challenge state and behavior.
| Method | Parameters | Returns | Description |
|---|---|---|---|
issue() |
CreatePasswordlessProps |
PasswordlessChallengeAggregate |
Creates and issues a new challenge, emitting PasswordlessChallengeIssuedEvent |
| 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 |
| Error | Condition |
|---|---|
PasswordlessChallengeExpiredError |
Challenge has passed expiration time |
PasswordlessChallengeAlreadyUsedError |
Challenge already verified or expired |
PasswordlessChallengeSecretMismatchError |
Provided secret doesn't match |
Application service for issuing new passwordless challenges.
constructor(
repository: PasswordlessChallengeRepository,
idGenerator: PasswordlessIdGeneratorPort,
clock: ClockPort
)| Method | Parameters | Returns | Description |
|---|---|---|---|
execute() |
Input |
Promise<Result<PasswordlessChallengeAggregate, Error>> |
Creates and persists a new challenge |
type Input = {
channel: PasswordlessChannel;
destination: ChallengeDestination;
secret: ChallengeSecret;
ttlSeconds?: ms.StringValue; // Default: '300s' (5 minutes)
};Application service for verifying passwordless challenges.
constructor(repository: PasswordlessChallengeRepository)| Method | Parameters | Returns | Description |
|---|---|---|---|
execute() |
Input |
Promise<Result<PasswordlessChallengeAggregate, Error>> |
Verifies a challenge with the provided secret and persists state |
type Input = {
id: PasswordlessChallengeId;
secret: ChallengeSecret;
};| 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.err(error).
| 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 |
| Event | Event Name | Payload |
|---|---|---|
PasswordlessChallengeIssuedEvent |
auth.passwordless.challenge_created |
{ channel, destination, expiresAt } |
PasswordlessChallengeVerifiedEvent |
auth.passwordless.challenge_verified |
{ channel, destination, verifiedAt } |
type PasswordlessChallengeRepository = {
save: (challenge: PasswordlessChallengeAggregate) => Promise<void>;
findById: (id: string) => Promise<PasswordlessChallengeAggregate | null>;
};type PasswordlessChannelPort = {
readonly channelName: PasswordlessChannel;
deliver: (
destination: ChallengeDestination,
secret: ChallengeSecret,
) => Promise<void>;
};type PasswordlessIdGeneratorPort = {
generate: () => PasswordlessChallengeId;
};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;
}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('user@example.com'),
secret: ChallengeSecret.create('123456'),
ttlSeconds: '5m',
});
if (result.isOk()) {
const challenge = result.value;
// Deliver secret via channel
}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
}
}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);declare module '@rineex/authentication-method-passwordless' {
interface PasswordlessChannelRegistry {
readonly custom_channel: true;
}
}- 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
- 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
issued ──verify()──> verified (final)
│
└──expires──> expired (final)
| Parameter | Default | Description |
|---|---|---|
ttlSeconds |
'300s' |
Challenge validity duration (accepts ms format: '5m', '300s', etc.) |
| 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 |
- 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
@rineex/auth-core: Core authentication abstractions@rineex/ddd: Domain-driven design primitives (AggregateRoot, ValueObject, DomainEvent, Result)ms: Time string parsing
Apache-2.0