@rineex/auth-core 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +1 -0
- package/package.json +5 -1
- package/src/application/mfa/events/challenge-issue-observability.event.ts +18 -0
- package/src/application/mfa/events/session-started-observability.event.ts +18 -0
- package/src/application/mfa/events/verification-failed-observability.event.ts +14 -0
- package/src/application/mfa/events/verification-succeeded-observibility.event.ts +12 -0
- package/src/application/mfa/issue-mfa-challenge.application-service.ts +75 -0
- package/src/application/mfa/start-mfa-session.application-service.ts +90 -0
- package/src/application/mfa/verify-mfa.application-service.ts +61 -0
- package/src/application/services/auth-orchestrator.service.ts +77 -0
- package/src/application/services/oauth-authorize.service.ts +12 -0
- package/src/domain/{aggregates → identity/aggregates}/authentication-attempt.aggregate.ts +43 -26
- package/src/domain/identity/aggregates/index.ts +1 -0
- package/src/domain/identity/entities/identity.entity.ts +126 -0
- package/src/domain/identity/entities/index.ts +1 -0
- package/src/domain/identity/events/index.ts +3 -0
- package/src/domain/identity/index.ts +4 -0
- package/src/domain/identity/value-objects/__tests__/auth-attempt-id.vo.spec.ts +42 -0
- package/src/domain/identity/value-objects/__tests__/auth-factor.vo.spec.ts +39 -0
- package/src/domain/identity/value-objects/__tests__/auth-method.vo.spec.ts +0 -0
- package/src/domain/{value-objects → identity/value-objects}/auth-attempt-id.vo.ts +4 -0
- package/src/domain/identity/value-objects/auth-factor.vo.ts +17 -0
- package/src/domain/identity/value-objects/auth-method.vo.ts +34 -0
- package/src/domain/identity/value-objects/auth-policy.vo.ts +19 -0
- package/src/domain/{value-objects → identity/value-objects}/identity-id.vo.ts +4 -0
- package/src/domain/identity/value-objects/identity-provider.vo.ts +13 -0
- package/src/domain/identity/value-objects/index.ts +8 -0
- package/src/domain/identity/value-objects/risk-signal.vo.ts +17 -0
- package/src/domain/index.ts +5 -0
- package/src/domain/mfa/aggregates/mfa-session.aggregate.ts +84 -0
- package/src/domain/mfa/entities/mfa-challenge.entity.ts +70 -0
- package/src/domain/mfa/types/mfa-challenge-registry.ts +21 -0
- package/src/domain/mfa/value-objects/mfa-challenge-id.vo.ts +19 -0
- package/src/domain/mfa/value-objects/mfa-challenge-status.vo.ts +31 -0
- package/src/domain/mfa/value-objects/mfa-session-id.vo.ts +19 -0
- package/src/domain/mfa/violations/mfa-active-challenge-exists.violation.ts +10 -0
- package/src/domain/mfa/violations/mfa-already-verified.violation.ts +10 -0
- package/src/domain/mfa/violations/mfa-attempts-exceeded.violation.ts +17 -0
- package/src/domain/mfa/violations/mfa-expired.violation.ts +10 -0
- package/src/domain/oauth/aggregates/oauth-authorization.aggregate.ts +106 -0
- package/src/domain/oauth/aggregates/oauth-authorize.service.ts +0 -0
- package/src/domain/oauth/entities/oauth-authorization.entity.ts +50 -0
- package/src/domain/oauth/value-objects/authorization-code-id.vo.ts +9 -0
- package/src/domain/oauth/value-objects/authorization-code.vo.ts +18 -0
- package/src/domain/oauth/value-objects/client-id.vo.ts +9 -0
- package/src/domain/oauth/value-objects/code-challenge-method.vo.ts +15 -0
- package/src/domain/oauth/value-objects/code-challenge.vo.ts +24 -0
- package/src/domain/oauth/value-objects/oauth-authorization-id.vo.ts +19 -0
- package/src/domain/oauth/value-objects/oauth-provider.vo.ts +15 -0
- package/src/domain/oauth/value-objects/pkce.vo.ts +29 -0
- package/src/domain/oauth/value-objects/redirect-uri.vo.ts +19 -0
- package/src/domain/oauth/value-objects/scope-set.vo.ts +37 -0
- package/src/domain/oauth/violations/authorization-already-used.violation.ts +10 -0
- package/src/domain/oauth/violations/authorization-expired.violation.ts +10 -0
- package/src/domain/oauth/violations/consent-required.violation.ts +10 -0
- package/src/domain/oauth/violations/invalid-authorization-code.violation.ts +12 -0
- package/src/domain/oauth/violations/invalid-oauth-provider.violation.ts +13 -0
- package/src/domain/oauth/violations/invalid-pkce.violation.ts +12 -0
- package/src/domain/oauth/violations/invalid-redirect-uri.violation.ts +10 -0
- package/src/domain/policy/contracts/auth-policy-context.ts +27 -0
- package/src/domain/policy/contracts/auth-policy-decision.ts +7 -0
- package/src/domain/policy/contracts/auth-policy.ts +17 -0
- package/src/domain/policy/contracts/index.ts +3 -0
- package/src/domain/policy/engine/auth-policy-engine.ts +41 -0
- package/src/domain/policy/index.ts +2 -0
- package/src/domain/session/entities/session.entity.ts +82 -0
- package/src/domain/session/value-objects/session-id.vo.ts +10 -0
- package/src/domain/token/aggregates/token.aggregate.ts +34 -0
- package/src/domain/token/value-objects/auth-token.vo.ts +29 -0
- package/src/domain/token/value-objects/session-token.vo.ts +14 -0
- package/src/domain/violations/auth-domain.violation.ts +9 -0
- package/src/domain/violations/invalid-auth-token.violation.ts +13 -0
- package/src/domain/violations/invalid-scope.violation.ts +10 -0
- package/src/domain/violations/invalid-session.violation.ts +13 -0
- package/src/index.ts +3 -1
- package/src/ports/inbound/auth-method.port.ts +1 -1
- package/src/ports/inbound/index.ts +2 -0
- package/src/ports/inbound/start-auth.command.ts +28 -0
- package/src/ports/index.ts +2 -0
- package/src/ports/log/log.port.ts +25 -0
- package/src/ports/mfa/mfa-clock.port.ts +11 -0
- package/src/ports/mfa/mfa-session-id-generator.port.ts +15 -0
- package/src/ports/mfa/mfa-session-repository.port.ts +31 -0
- package/src/ports/observability/observability-event.port.ts +16 -0
- package/src/ports/outbound/authentication-attempt.repository.port.ts +1 -1
- package/src/ports/outbound/domain-event-publisher.port.ts +13 -0
- package/src/ports/outbound/index.ts +2 -0
- package/src/ports/outbound/session.repository.port.ts +9 -0
- package/src/ports/repositories/oauth-authorization.repository.ts +21 -0
- package/src/ports/repositories/token.repository.ts +11 -0
- package/src/types/auth-factor.type.ts +10 -0
- package/src/types/auth-method.type.ts +20 -0
- package/src/types/auth-policy.type.ts +16 -0
- package/src/types/identity-provider.type.ts +8 -0
- package/src/types/index.ts +6 -0
- package/src/types/observability-event.ts +33 -0
- package/src/types/risk-signal.type.ts +11 -0
- package/src/utils/default-if-blank.util.ts +46 -0
- package/tsconfig.json +4 -1
- package/src/domain/entities/identity.entity.ts +0 -13
- package/src/domain/value-objects/auth-method.vo.ts +0 -21
- /package/src/domain/{events → identity/events}/authentication-failed.event.ts +0 -0
- /package/src/domain/{events → identity/events}/authentication-started.event.ts +0 -0
- /package/src/domain/{events → identity/events}/authentication-succeeded.event.ts +0 -0
- /package/src/domain/{value-objects → identity/value-objects}/auth-status.vo.ts +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CreateEntityProps, Entity } from '@rineex/ddd';
|
|
2
|
+
|
|
3
|
+
import { IdentityId } from '../value-objects/identity-id.vo';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Properties owned by the Identity entity.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT:
|
|
9
|
+
* - This is NOT a user profile
|
|
10
|
+
* - This is NOT authorization data
|
|
11
|
+
* - This represents a stable authentication identity
|
|
12
|
+
*
|
|
13
|
+
* Examples:
|
|
14
|
+
* - Email-based identity
|
|
15
|
+
* - External IdP subject
|
|
16
|
+
* - Service or machine identity
|
|
17
|
+
*/
|
|
18
|
+
export interface IdentityCreateProps extends CreateEntityProps<IdentityId> {
|
|
19
|
+
readonly isActive: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Identity entity.
|
|
24
|
+
*
|
|
25
|
+
* Represents "who is authenticating" in the system.
|
|
26
|
+
*
|
|
27
|
+
* This entity is intentionally minimal and stable.
|
|
28
|
+
* Any additional concerns (profile, roles, permissions)
|
|
29
|
+
* MUST live in other bounded contexts.
|
|
30
|
+
*/
|
|
31
|
+
export class Identity extends Entity<IdentityId> {
|
|
32
|
+
// We keep the state private to the class
|
|
33
|
+
private _isActive: boolean;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Private constructor forces usage of factory methods.
|
|
37
|
+
*/
|
|
38
|
+
private constructor({ isActive, ...props }: IdentityCreateProps) {
|
|
39
|
+
super(props);
|
|
40
|
+
this._isActive = isActive;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Factory method to create a new identity.
|
|
45
|
+
*/
|
|
46
|
+
static create(id: IdentityId, isActive: boolean = true): Identity {
|
|
47
|
+
return new Identity({
|
|
48
|
+
isActive,
|
|
49
|
+
id,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Getter for the active state
|
|
55
|
+
*/
|
|
56
|
+
public get isActive(): boolean {
|
|
57
|
+
return this._isActive;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Disables this identity.
|
|
62
|
+
*/
|
|
63
|
+
public disable(): void {
|
|
64
|
+
if (!this._isActive) return;
|
|
65
|
+
|
|
66
|
+
this.mutate(draft => {
|
|
67
|
+
draft._isActive = false;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Enables this identity.
|
|
73
|
+
*/
|
|
74
|
+
public enable(): void {
|
|
75
|
+
if (this._isActive) return;
|
|
76
|
+
|
|
77
|
+
this.mutate(draft => {
|
|
78
|
+
draft._isActive = true;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Domain invariant validation.
|
|
84
|
+
*/
|
|
85
|
+
public validate(): void {
|
|
86
|
+
if (this.id == null) {
|
|
87
|
+
throw new Error('Identity must have a valid IdentityId');
|
|
88
|
+
}
|
|
89
|
+
if (typeof this._isActive !== 'boolean') {
|
|
90
|
+
throw new Error('Identity.isActive must be a boolean');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serialization to plain object.
|
|
96
|
+
*/
|
|
97
|
+
public toObject(): Record<string, unknown> {
|
|
98
|
+
return {
|
|
99
|
+
createdAt: this.createdAt.toISOString(),
|
|
100
|
+
isActive: this._isActive,
|
|
101
|
+
id: this.id.toString(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates a snapshot of mutable state.
|
|
107
|
+
* Identity fields MUST NOT be included.
|
|
108
|
+
*
|
|
109
|
+
* Used internally for rollback.
|
|
110
|
+
*/
|
|
111
|
+
protected snapshot(): Record<string, unknown> {
|
|
112
|
+
return {
|
|
113
|
+
isActive: this._isActive,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Restores mutable state from a snapshot.
|
|
119
|
+
* Identity fields MUST NOT be modified.
|
|
120
|
+
*
|
|
121
|
+
* @param snapshot - Previously captured state.
|
|
122
|
+
*/
|
|
123
|
+
protected restore(snapshot: Record<string, unknown>): void {
|
|
124
|
+
this._isActive = snapshot.isActive as boolean;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './identity.entity';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AuthAttemptId } from '../auth-attempt-id.vo';
|
|
4
|
+
|
|
5
|
+
describe('authAttemptId', () => {
|
|
6
|
+
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000';
|
|
7
|
+
|
|
8
|
+
it('should create a valid AuthAttemptId with a valid UUID string', () => {
|
|
9
|
+
const authAttemptId = AuthAttemptId.create(VALID_UUID);
|
|
10
|
+
|
|
11
|
+
expect(authAttemptId).toBeInstanceOf(AuthAttemptId);
|
|
12
|
+
expect(authAttemptId.getValue()).toBe(VALID_UUID);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should throw an error when creating with an empty string', () => {
|
|
16
|
+
expect(() => AuthAttemptId.create('')).toThrow(
|
|
17
|
+
'AuthAttemptId must be a valid non-empty identifier',
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should throw an error when creating with a string shorter than 16 characters', () => {
|
|
22
|
+
expect(() => AuthAttemptId.create('short')).toThrow(
|
|
23
|
+
'AuthAttemptId must be a valid non-empty identifier',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should throw an error when creating with null or undefined', () => {
|
|
28
|
+
expect(() => AuthAttemptId.create(null as any)).toThrow(
|
|
29
|
+
'AuthAttemptId must be a valid non-empty identifier',
|
|
30
|
+
);
|
|
31
|
+
expect(() => AuthAttemptId.create(undefined as any)).toThrow(
|
|
32
|
+
'AuthAttemptId must be a valid non-empty identifier',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should accept a valid string with 16 or more characters', () => {
|
|
37
|
+
const validId = '1234567890123456';
|
|
38
|
+
const authAttemptId = AuthAttemptId.create(validId);
|
|
39
|
+
|
|
40
|
+
expect(authAttemptId.getValue()).toBe(validId);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AuthFactor } from '../auth-factor.vo';
|
|
4
|
+
|
|
5
|
+
describe('authFactor', () => {
|
|
6
|
+
it('should create a valid AuthFactor with a valid identifier', () => {
|
|
7
|
+
const factor = AuthFactor.create('password');
|
|
8
|
+
|
|
9
|
+
expect(factor.toString()).toBe('password');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should create a valid AuthFactor with different factor types', () => {
|
|
13
|
+
const factors = ['mfa', 'biometric', 'email', 'sms'];
|
|
14
|
+
|
|
15
|
+
factors.forEach(factorType => {
|
|
16
|
+
const factor = AuthFactor.create(factorType as any);
|
|
17
|
+
|
|
18
|
+
expect(factor.toString()).toBe(factorType);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should throw an error when created with empty value', () => {
|
|
23
|
+
expect(() => AuthFactor.create('' as any)).toThrow(
|
|
24
|
+
'AuthFactor must be a valid identifier',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should throw an error when created with null value', () => {
|
|
29
|
+
expect(() => AuthFactor.create(null as any)).toThrow(
|
|
30
|
+
'AuthFactor must be a valid identifier',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should throw an error when created with undefined value', () => {
|
|
35
|
+
expect(() => AuthFactor.create(undefined as any)).toThrow(
|
|
36
|
+
'AuthFactor must be a valid identifier',
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AuthFactorName } from '@/types/auth-factor.type';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents an authentication factor.
|
|
6
|
+
*/
|
|
7
|
+
export class AuthFactor extends PrimitiveValueObject<AuthFactorName> {
|
|
8
|
+
protected validate(value: AuthFactorName): void {
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error('AuthFactor must be a valid identifier');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public static create(value: AuthFactorName): AuthFactor {
|
|
15
|
+
return new AuthFactor(value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AuthMethodName } from '@/types/auth-method.type';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents the authentication method requested or used.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* - passwordless
|
|
9
|
+
* - otp
|
|
10
|
+
* - oauth
|
|
11
|
+
* - oidc
|
|
12
|
+
* - passkey
|
|
13
|
+
*
|
|
14
|
+
* This is a value object, NOT a strategy.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthMethod extends PrimitiveValueObject<AuthMethodName> {
|
|
17
|
+
protected validate(value: AuthMethodName): void {
|
|
18
|
+
if (!value) {
|
|
19
|
+
throw new Error('AuthMethod cannot be empty');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static create(value: AuthMethodName): AuthMethod {
|
|
24
|
+
return new AuthMethod(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public is(method: AuthMethodName): boolean {
|
|
28
|
+
return this.value === method;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public isNot(method: AuthMethodName): boolean {
|
|
32
|
+
return this.value !== method;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AuthPolicyName } from '@/types/auth-policy.type';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a policy applied during authentication orchestration.
|
|
6
|
+
*
|
|
7
|
+
* Policies influence decisions but do not authenticate users.
|
|
8
|
+
*/
|
|
9
|
+
export class AuthPolicy extends PrimitiveValueObject<AuthPolicyName> {
|
|
10
|
+
protected validate(value: AuthPolicyName): void {
|
|
11
|
+
if (!value) {
|
|
12
|
+
throw new Error('AuthPolicy must be a valid identifier');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public static create(value: AuthPolicyName): AuthPolicy {
|
|
17
|
+
return new AuthPolicy(value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { IdentityProviderName } from '@/types/identity-provider.type';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents an external or internal identity provider.
|
|
6
|
+
*/
|
|
7
|
+
export class IdentityProvider extends PrimitiveValueObject<IdentityProviderName> {
|
|
8
|
+
protected validate(value: IdentityProviderName): void {
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error('IdentityProvider must be a valid identifier');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './auth-attempt-id.vo';
|
|
2
|
+
export * from './auth-factor.vo';
|
|
3
|
+
export * from './auth-method.vo';
|
|
4
|
+
export * from './auth-policy.vo';
|
|
5
|
+
export * from './auth-status.vo';
|
|
6
|
+
export * from './identity-id.vo';
|
|
7
|
+
export * from './identity-provider.vo';
|
|
8
|
+
export * from './risk-signal.vo';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RiskSignalName } from '@/types/risk-signal.type';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a detected risk signal during authentication.
|
|
6
|
+
*/
|
|
7
|
+
export class RiskSignal extends PrimitiveValueObject<RiskSignalName> {
|
|
8
|
+
protected validate(value: RiskSignalName): void {
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error('RiskSignal must be a valid identifier');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public static create(value: RiskSignalName): RiskSignal {
|
|
15
|
+
return new RiskSignal(value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AggregateRoot, CreateEntityProps } from '@rineex/ddd';
|
|
2
|
+
import { defaultIfBlank } from '@/utils/default-if-blank.util';
|
|
3
|
+
import { IdentityId } from '@/index';
|
|
4
|
+
|
|
5
|
+
import { MfaActiveChallengeExistsViolation } from '../violations/mfa-active-challenge-exists.violation';
|
|
6
|
+
import { MfaAttemptsExceededViolation } from '../violations/mfa-attempts-exceeded.violation';
|
|
7
|
+
import { MfaAlreadyVerifiedViolation } from '../violations/mfa-already-verified.violation';
|
|
8
|
+
import { MfaSessionId } from '../value-objects/mfa-session-id.vo';
|
|
9
|
+
import { MFAChallenge } from '../entities/mfa-challenge.entity';
|
|
10
|
+
|
|
11
|
+
export interface MfaSessionProps extends CreateEntityProps<MfaSessionId> {
|
|
12
|
+
readonly identityId: IdentityId;
|
|
13
|
+
challenges: MFAChallenge[];
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
attemptsUsed: number;
|
|
16
|
+
verifiedAt?: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class MFASession extends AggregateRoot<MfaSessionId> {
|
|
20
|
+
constructor(public readonly props: MfaSessionProps) {
|
|
21
|
+
super(props);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toObject(): Record<string, unknown> {
|
|
25
|
+
return {
|
|
26
|
+
verifiedAt: defaultIfBlank(this.props.verifiedAt?.toISOString(), null),
|
|
27
|
+
challenges: this.props.challenges.map(c => c.toObject()),
|
|
28
|
+
identityId: this.props.identityId.toString(),
|
|
29
|
+
attemptsUsed: this.props.attemptsUsed,
|
|
30
|
+
maxAttempts: this.props.maxAttempts,
|
|
31
|
+
id: this.id.toString(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
validate(): void {
|
|
36
|
+
if (this.props.attemptsUsed > this.props.maxAttempts) {
|
|
37
|
+
throw MfaAttemptsExceededViolation.create(
|
|
38
|
+
this.props.attemptsUsed,
|
|
39
|
+
this.props.maxAttempts,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (this.props.verifiedAt && this.props.challenges.length > 0) {
|
|
44
|
+
throw new Error('Verified MFA session cannot have active challenges');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get isVerified(): boolean {
|
|
49
|
+
return this.props.verifiedAt !== undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
issueChallenge(challenge: MFAChallenge, now: Date): void {
|
|
53
|
+
if (this.isVerified) throw MfaAlreadyVerifiedViolation.create();
|
|
54
|
+
|
|
55
|
+
const hasActive = this.props.challenges.some(c => !c.isExpired(now));
|
|
56
|
+
|
|
57
|
+
if (hasActive) {
|
|
58
|
+
throw MfaActiveChallengeExistsViolation.create();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.props.challenges.push(challenge);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
markAttempt(): void {
|
|
65
|
+
this.props.attemptsUsed += 1;
|
|
66
|
+
this.validate();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
verify(now: Date): void {
|
|
70
|
+
if (this.isVerified) throw MfaAlreadyVerifiedViolation.create();
|
|
71
|
+
|
|
72
|
+
this.mutate(draft => {
|
|
73
|
+
draft.props.challenges = draft.props.challenges.filter(
|
|
74
|
+
c => !c.isExpired(now),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (draft.props.challenges.length === 0) {
|
|
78
|
+
throw new Error('No valid MFA challenges to verify');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
draft.props.verifiedAt = now;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AuthDomainViolation } from '@/domain/violations/auth-domain.violation';
|
|
2
|
+
import { CreateEntityProps, Entity } from '@rineex/ddd';
|
|
3
|
+
import { IdentityId } from '@/index';
|
|
4
|
+
|
|
5
|
+
import { MfaChallengeId } from '../value-objects/mfa-challenge-id.vo';
|
|
6
|
+
import { MfaChallengeType } from '../types/mfa-challenge-registry';
|
|
7
|
+
|
|
8
|
+
export interface Props extends CreateEntityProps<MfaChallengeId> {
|
|
9
|
+
/**
|
|
10
|
+
* Authentication identity this challenge is bound to.
|
|
11
|
+
* This is NOT an application user.
|
|
12
|
+
*/
|
|
13
|
+
readonly identityId: IdentityId;
|
|
14
|
+
|
|
15
|
+
readonly challengeType: MfaChallengeType;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Challenge issue time.
|
|
19
|
+
*/
|
|
20
|
+
readonly issuedAt: Date;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Absolute expiration timestamp.
|
|
24
|
+
*/
|
|
25
|
+
readonly expiresAt: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class MfaChallengeExpiredViolation extends AuthDomainViolation {
|
|
29
|
+
readonly code = 'MFA_CHALLENGE_EXPIRED';
|
|
30
|
+
readonly message = 'MFA challenge has expired';
|
|
31
|
+
|
|
32
|
+
static create(reason: string) {
|
|
33
|
+
return new MfaChallengeExpiredViolation({ reason });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class MFAChallenge extends Entity<MfaChallengeId> {
|
|
38
|
+
constructor(public readonly props: Props) {
|
|
39
|
+
super(props);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
toObject(): Record<string, unknown> {
|
|
43
|
+
return {
|
|
44
|
+
identityId: this.props.identityId.toString(),
|
|
45
|
+
challengeType: this.props.challengeType,
|
|
46
|
+
expiresAt: this.props.expiresAt,
|
|
47
|
+
issuedAt: this.props.issuedAt,
|
|
48
|
+
id: this.id.toString(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
validate(): void {
|
|
53
|
+
if (this.props.expiresAt <= this.props.issuedAt) {
|
|
54
|
+
throw MfaChallengeExpiredViolation.create(
|
|
55
|
+
'MFA challenge expiration must be after issue time',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public static create(props: Props): MFAChallenge {
|
|
61
|
+
return new MFAChallenge(props);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks whether the challenge is expired.
|
|
66
|
+
*/
|
|
67
|
+
isExpired(now: Date): boolean {
|
|
68
|
+
return now > this.props.expiresAt;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for MFA challenge mechanisms.
|
|
3
|
+
*
|
|
4
|
+
* This registry is intentionally open for module augmentation.
|
|
5
|
+
* Each MFA-related package can extend it safely.
|
|
6
|
+
*/
|
|
7
|
+
export type MfaChallengeTypeRegistry = {
|
|
8
|
+
readonly authenticator_app: true;
|
|
9
|
+
readonly email: true;
|
|
10
|
+
readonly sms: true;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Union of all registered MFA challenge types.
|
|
15
|
+
*
|
|
16
|
+
* Provides:
|
|
17
|
+
* - IDE autocomplete
|
|
18
|
+
* - Type safety
|
|
19
|
+
* - Extension without modifying core
|
|
20
|
+
*/
|
|
21
|
+
export type MfaChallengeType = keyof MfaChallengeTypeRegistry;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AuthDomainViolation } from '@/domain/violations/auth-domain.violation';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
class InvalidMfaChallengeIdViolation extends AuthDomainViolation {
|
|
5
|
+
readonly code = 'MFA_CHALLENGE_ID_INVALID';
|
|
6
|
+
readonly message = 'MFA challenge ID is invalid';
|
|
7
|
+
|
|
8
|
+
static create(props: { value: string }) {
|
|
9
|
+
return new InvalidMfaChallengeIdViolation(props);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MfaChallengeId extends PrimitiveValueObject<string> {
|
|
14
|
+
protected validate(value: string): void {
|
|
15
|
+
if (!value || value.length < 16) {
|
|
16
|
+
throw InvalidMfaChallengeIdViolation.create({ value });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AuthDomainViolation } from '@/domain/violations/auth-domain.violation';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
const AllowedStatuses = ['pending', 'verified', 'expired', 'failed'] as const;
|
|
5
|
+
|
|
6
|
+
type MfaChallengeStatusValue = (typeof AllowedStatuses)[number];
|
|
7
|
+
|
|
8
|
+
class InvalidMfaChallengeStatusViolation extends AuthDomainViolation {
|
|
9
|
+
readonly code = 'MFA_CHALLENGE_STATUS_INVALID';
|
|
10
|
+
readonly message = 'MFA challenge status is invalid';
|
|
11
|
+
|
|
12
|
+
static create(props: { value: MfaChallengeStatusValue }) {
|
|
13
|
+
return new InvalidMfaChallengeStatusViolation(props);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MfaChallengeStatus extends PrimitiveValueObject<MfaChallengeStatusValue> {
|
|
18
|
+
protected validate(value: MfaChallengeStatusValue): void {
|
|
19
|
+
if (!AllowedStatuses.includes(value)) {
|
|
20
|
+
throw InvalidMfaChallengeStatusViolation.create({ value });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static pending() {
|
|
25
|
+
return new MfaChallengeStatus('pending');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static verified() {
|
|
29
|
+
return new MfaChallengeStatus('verified');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AuthDomainViolation } from '@/domain/violations/auth-domain.violation';
|
|
2
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
3
|
+
|
|
4
|
+
class InvalidMfaSessionIdViolation extends AuthDomainViolation {
|
|
5
|
+
readonly code = 'MFA_SESSION_ID_INVALID';
|
|
6
|
+
readonly message = 'MFA session ID is invalid';
|
|
7
|
+
|
|
8
|
+
static create(props: { value: string }) {
|
|
9
|
+
return new InvalidMfaSessionIdViolation(props);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MfaSessionId extends PrimitiveValueObject<string> {
|
|
14
|
+
protected validate(value: string): void {
|
|
15
|
+
if (!value || value.length < 16) {
|
|
16
|
+
throw InvalidMfaSessionIdViolation.create({ value });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainViolation } from '@rineex/ddd';
|
|
2
|
+
|
|
3
|
+
export class MfaActiveChallengeExistsViolation extends DomainViolation {
|
|
4
|
+
readonly code = 'MFA_ACTIVE_CHALLENGE_EXISTS';
|
|
5
|
+
readonly message = 'An active MFA challenge already exists';
|
|
6
|
+
|
|
7
|
+
static create(): MfaActiveChallengeExistsViolation {
|
|
8
|
+
return new MfaActiveChallengeExistsViolation();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainViolation } from '@rineex/ddd';
|
|
2
|
+
|
|
3
|
+
export class MfaAlreadyVerifiedViolation extends DomainViolation {
|
|
4
|
+
readonly code = 'MFA_ALREADY_VERIFIED';
|
|
5
|
+
readonly message = 'MFA session is already verified';
|
|
6
|
+
|
|
7
|
+
static create(): MfaAlreadyVerifiedViolation {
|
|
8
|
+
return new MfaAlreadyVerifiedViolation();
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DomainViolation } from '@rineex/ddd';
|
|
2
|
+
|
|
3
|
+
export class MfaAttemptsExceededViolation extends DomainViolation {
|
|
4
|
+
readonly code = 'MFA_ATTEMPTS_EXCEEDED';
|
|
5
|
+
readonly message = 'Maximum MFA verification attempts exceeded';
|
|
6
|
+
|
|
7
|
+
protected constructor(attemptsUsed: number, maxAttempts: number) {
|
|
8
|
+
super({ attemptsUsed, maxAttempts });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static create(
|
|
12
|
+
attemptsUsed: number,
|
|
13
|
+
maxAttempts: number,
|
|
14
|
+
): MfaAttemptsExceededViolation {
|
|
15
|
+
return new MfaAttemptsExceededViolation(attemptsUsed, maxAttempts);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DomainViolation } from '@rineex/ddd';
|
|
2
|
+
|
|
3
|
+
export class MfaExpiredViolation extends DomainViolation {
|
|
4
|
+
readonly code = 'MFA_EXPIRED';
|
|
5
|
+
readonly message = 'MFA challenge or session has expired';
|
|
6
|
+
|
|
7
|
+
static create() {
|
|
8
|
+
return new MfaExpiredViolation();
|
|
9
|
+
}
|
|
10
|
+
}
|