@owlmeans/server-auth-otp 0.1.18-rc.34 → 0.1.18-rc.35
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/README.md +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-otp/SKILL.md +20 -24
- package/build/challenge.d.ts +8 -0
- package/build/challenge.d.ts.map +1 -0
- package/build/challenge.js +98 -0
- package/build/challenge.js.map +1 -0
- package/build/consts.d.ts +10 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +7 -1
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts +18 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +32 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +4 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -0
- package/build/index.js.map +1 -1
- package/build/plugin.d.ts.map +1 -1
- package/build/plugin.js +11 -13
- package/build/plugin.js.map +1 -1
- package/build/service.d.ts +3 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +45 -22
- package/build/service.js.map +1 -1
- package/build/throttle.d.ts +12 -0
- package/build/throttle.d.ts.map +1 -0
- package/build/throttle.js +93 -0
- package/build/throttle.js.map +1 -0
- package/build/types.d.ts +34 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js +7 -1
- package/build/types.js.map +1 -1
- package/package.json +12 -12
- package/src/challenge.ts +114 -0
- package/src/consts.ts +11 -1
- package/src/errors.ts +37 -0
- package/src/index.ts +4 -1
- package/src/plugin.ts +11 -13
- package/src/service.ts +59 -34
- package/src/throttle.ts +120 -0
- package/src/types.ts +42 -0
- package/tests/context.ts +43 -0
- package/tests/keys.spec.ts +23 -0
- package/tests/service.spec.ts +88 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Email OTP `AuthPlugin` for OwlMeans servers — passwordless login via time-limi
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
bun add @owlmeans/server-auth-otp@^0.1.18-rc.
|
|
15
|
+
bun add @owlmeans/server-auth-otp@^0.1.18-rc.35
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
@@ -47,7 +47,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
47
47
|
your project's skill store (`.agents/skills/`):
|
|
48
48
|
|
|
49
49
|
```sh
|
|
50
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
50
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.30
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-auth-otp",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.35",
|
|
5
|
+
"generatedAt": "2026-09-19T13:42:56.249Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,18 +8,17 @@ metadata:
|
|
|
8
8
|
|
|
9
9
|
# Using `@owlmeans/server-auth-otp`
|
|
10
10
|
|
|
11
|
-
**Install:** `"@owlmeans/server-auth-otp": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/server-auth-otp": "^0.1.18-rc.35"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
Email OTP authentication plugin for the OwlMeans auth-manager plugin system. Relies on `@owlmeans/auth-otp` for the OTP service interface, a Redis resource for code storage, and a `MailerService` to send codes.
|
|
14
14
|
|
|
15
15
|
## `@owlmeans/auth-otp` — the contracts
|
|
16
16
|
|
|
17
|
-
`@owlmeans/auth-otp` has no skill of its own because it
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
`OTP_CODE_LENGTH` (6). Nothing else; no Redis, no mailer, no plugin.
|
|
17
|
+
`@owlmeans/auth-otp` has no skill of its own because it is the contracts half of this pair. Its
|
|
18
|
+
`OtpService` issues an opaque issuance id, persists and mails a code, and verifies that exact
|
|
19
|
+
issuance; it also owns `OTP_SERVICE`, `OTP_AUTH_TYPE` (`'email-otp'`), `OTP_RESOURCE`,
|
|
20
|
+
`OTP_TTL_SECONDS` (600), `OTP_CODE_LENGTH` (6), and the five-attempt policy. The server package
|
|
21
|
+
provides the challenge stores, throttles, mailer integration and plugin.
|
|
23
22
|
|
|
24
23
|
Depend on it from a shared package that must name the auth type or type the service, and depend on
|
|
25
24
|
`@owlmeans/server-auth-otp` only where the server wires itself up — the same producer/consumer split
|
|
@@ -67,13 +66,11 @@ appendOtpPlugin(context)
|
|
|
67
66
|
|
|
68
67
|
**Init** — client sends `{ type: 'email-otp', userId: 'user@email.com' }`:
|
|
69
68
|
- OTP service generates a 6-digit code, stores it in Redis with 10 min TTL, emails it.
|
|
70
|
-
- Returns
|
|
71
|
-
nonce is required, not optional.
|
|
69
|
+
- Returns an opaque issuance id in a signed envelope. The id never contains the email or code.
|
|
72
70
|
|
|
73
71
|
**Authenticate** — client sends `{ challenge: <signed-envelope>, userId: email, credential: '123456', type: 'email-otp', role: AuthRole.User, scopes: [ALL_SCOPES] }`:
|
|
74
|
-
- Envelope is opened →
|
|
75
|
-
|
|
76
|
-
- OTP service verifies the code (throws `AuthenFailed` if wrong or expired), then deletes it.
|
|
72
|
+
- Envelope is opened → the plugin verifies the opaque issuance and code atomically. Five failed
|
|
73
|
+
attempts invalidate it; one concurrent correct verification can consume it.
|
|
77
74
|
- `IdentityLinkingService` finds the linked profile, or links this email to the person's platform
|
|
78
75
|
identity — registering an account, a profile and an organization entity only when the address is
|
|
79
76
|
new to the platform.
|
|
@@ -98,7 +95,9 @@ cfg.otp = {
|
|
|
98
95
|
|
|
99
96
|
## Rules
|
|
100
97
|
|
|
101
|
-
- Always register the Redis
|
|
98
|
+
- Always register the Redis challenge store, Redis throttle service, and mailer service BEFORE the
|
|
99
|
+
OTP service. Production uses `makeRedisOtpChallengeStore` and `makeRedisThrottleService`; memory
|
|
100
|
+
implementations are local/test defaults only.
|
|
102
101
|
- Call `appendOtpPlugin(context)` once per context — it adds to the shared plugin registry singleton.
|
|
103
102
|
- `credential.entitySlug` on the authenticate request **selects nothing**. The plugin copies it into
|
|
104
103
|
the linking details as `clientId` (defaulting to `'default'`) and `entityId`, and
|
|
@@ -109,23 +108,20 @@ cfg.otp = {
|
|
|
109
108
|
slug before the envelope is signed, so the address alone decides which identity and which
|
|
110
109
|
organization the token names.
|
|
111
110
|
- Errors from this plugin are `AuthenFailed` (from `@owlmeans/auth`) — callers catch that, not raw `Error`.
|
|
111
|
+
- Use the email throttle for the public integrated-IAM flow: one issuance per minute and ten per
|
|
112
|
+
hour, with a 429 and Retry-After. The companion IP throttle is available for an ingress that
|
|
113
|
+
safely supplies a normalized client address; throttle keys hash email/IP values and never store
|
|
114
|
+
the raw identifier.
|
|
112
115
|
- For tests, register `makeDefaultConsoleMailerService()` and read `svc.captured[n].text` to extract
|
|
113
116
|
the code. Bare `makeConsoleMailerService()` registers under `CONSOLE_MAILER` (`'console-mailer'`),
|
|
114
117
|
which is not the alias `makeOtpService` resolves — it looks up `cfg.otp?.mailerAlias ?? MAILER_SERVICE`.
|
|
115
118
|
|
|
116
119
|
## Gotchas
|
|
117
120
|
|
|
118
|
-
- **The challenge must
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
are the only thing this package keeps in Redis. If `init()` returned the bare email, that decoded
|
|
123
|
-
value would be identical across every independent login attempt for the same address, so a second
|
|
124
|
-
legitimate login within the cache TTL (`AUTHEN_TIMEFRAME`, 15 min) — right code or wrong —
|
|
125
|
-
collides with the still-cached prior attempt and throws `AuthenFailed('challenge')` (the
|
|
126
|
-
resource's `RecordExists` underneath), not an OTP-specific error. Fix: `init()` appends a fresh
|
|
127
|
-
`createIdOfLength(16, IdStyle.Base58)` nonce (`'<email>::<nonce>'`); `authenticate()` splits it
|
|
128
|
-
back apart. Never revert to a bare-email challenge.
|
|
121
|
+
- **The OTP challenge must be opaque and plugin-owned.** The generic auth-manager replay policy
|
|
122
|
+
does not consume it before the OTP store can count attempts; the plugin atomically owns verify
|
|
123
|
+
and consume. Never encode the email or code in the issuance id, and never replace the bounded
|
|
124
|
+
attempt counter with a bare auth-cache replay key.
|
|
129
125
|
- **`AuthCredentialsSchema.credential` has a `minLength` floor** (from `@owlmeans/auth`) sized for
|
|
130
126
|
long tokens/signatures from other plugins (Ed25519 signature, OAuth code). A 6-digit OTP code is
|
|
131
127
|
legitimately shorter — the floor is `minLength: 1` and must stay low enough to admit it, or every
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OtpChallengeStore } from './types.js';
|
|
2
|
+
type Clock = () => number;
|
|
3
|
+
/** Real in-memory challenge store with synchronous consume/update semantics. */
|
|
4
|
+
export declare const makeMemoryOtpChallengeStore: (alias?: string, now?: Clock) => OtpChallengeStore;
|
|
5
|
+
/** Redis challenge store: compare, attempt increment, exhaustion, and consume are one operation. */
|
|
6
|
+
export declare const makeRedisOtpChallengeStore: (alias?: string, resourceAlias?: string) => OtpChallengeStore;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=challenge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"challenge.d.ts","sourceRoot":"","sources":["../src/challenge.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACyB,iBAAiB,EACrD,MAAM,YAAY,CAAA;AAGnB,KAAK,KAAK,GAAG,MAAM,MAAM,CAAA;AAGzB,gFAAgF;AAChF,eAAO,MAAM,2BAA2B,yBACJ,KAAK,KACtC,iBA+BF,CAAA;AA0BD,oGAAoG;AACpG,eAAO,MAAM,0BAA0B,8CAEpC,iBAqCF,CAAA"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { appendContextual, createService } from '@owlmeans/context';
|
|
2
|
+
import { OtpUnavailable } from './errors.js';
|
|
3
|
+
import { OTP_CHALLENGE_STORE, OTP_RESOURCE } from './consts.js';
|
|
4
|
+
import { OtpChallengeOutcome as Outcome } from './types.js';
|
|
5
|
+
/** Real in-memory challenge store with synchronous consume/update semantics. */
|
|
6
|
+
export const makeMemoryOtpChallengeStore = (alias = OTP_CHALLENGE_STORE, now = Date.now) => {
|
|
7
|
+
const records = new Map();
|
|
8
|
+
return createService(alias, {
|
|
9
|
+
issue: async (challenge, ttlSeconds) => {
|
|
10
|
+
const current = records.get(challenge.id);
|
|
11
|
+
if (current != null && current.expiresAt > now())
|
|
12
|
+
return false;
|
|
13
|
+
records.set(challenge.id, {
|
|
14
|
+
...challenge, failedAttempts: 0, expiresAt: now() + ttlSeconds * 1000,
|
|
15
|
+
});
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
verify: async (id, emailKey, codeHash, maxFailedAttempts) => {
|
|
19
|
+
const record = records.get(id);
|
|
20
|
+
if (record == null || record.expiresAt <= now()) {
|
|
21
|
+
records.delete(id);
|
|
22
|
+
return Outcome.Missing;
|
|
23
|
+
}
|
|
24
|
+
if (record.emailKey === emailKey && record.codeHash === codeHash) {
|
|
25
|
+
records.delete(id);
|
|
26
|
+
return Outcome.Verified;
|
|
27
|
+
}
|
|
28
|
+
record.failedAttempts += 1;
|
|
29
|
+
if (record.failedAttempts >= maxFailedAttempts) {
|
|
30
|
+
records.delete(id);
|
|
31
|
+
return Outcome.Exhausted;
|
|
32
|
+
}
|
|
33
|
+
return Outcome.Invalid;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const REDIS_VERIFY_SCRIPT = `
|
|
38
|
+
local raw = redis.call('GET', KEYS[1])
|
|
39
|
+
if not raw then return 0 end
|
|
40
|
+
local record = cjson.decode(raw)
|
|
41
|
+
if record.emailKey == ARGV[1] and record.codeHash == ARGV[2] then
|
|
42
|
+
redis.call('DEL', KEYS[1])
|
|
43
|
+
return 1
|
|
44
|
+
end
|
|
45
|
+
record.failedAttempts = tonumber(record.failedAttempts or 0) + 1
|
|
46
|
+
if record.failedAttempts >= tonumber(ARGV[3]) then
|
|
47
|
+
redis.call('DEL', KEYS[1])
|
|
48
|
+
return -2
|
|
49
|
+
end
|
|
50
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
51
|
+
if ttl <= 0 then
|
|
52
|
+
redis.call('DEL', KEYS[1])
|
|
53
|
+
return 0
|
|
54
|
+
end
|
|
55
|
+
redis.call('SET', KEYS[1], cjson.encode(record), 'XX', 'PX', ttl)
|
|
56
|
+
return -1
|
|
57
|
+
`;
|
|
58
|
+
/** Redis challenge store: compare, attempt increment, exhaustion, and consume are one operation. */
|
|
59
|
+
export const makeRedisOtpChallengeStore = (alias = OTP_CHALLENGE_STORE, resourceAlias = OTP_RESOURCE) => {
|
|
60
|
+
const service = appendContextual(alias, {
|
|
61
|
+
issue: async (challenge, ttlSeconds) => {
|
|
62
|
+
const ctx = service.ctx;
|
|
63
|
+
const resource = ctx.resource(resourceAlias);
|
|
64
|
+
try {
|
|
65
|
+
const result = await resource.db.client.set(resource.key(challenge.id), JSON.stringify({ ...challenge, failedAttempts: 0 }), 'EX', ttlSeconds, 'NX');
|
|
66
|
+
return result === 'OK';
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const unavailable = new OtpUnavailable('challenge');
|
|
70
|
+
if (error instanceof Error)
|
|
71
|
+
unavailable.oiriginalStack = `${error}: ${error.stack}`;
|
|
72
|
+
throw unavailable;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
verify: async (id, emailKey, codeHash, maxFailedAttempts) => {
|
|
76
|
+
const ctx = service.ctx;
|
|
77
|
+
const resource = ctx.resource(resourceAlias);
|
|
78
|
+
try {
|
|
79
|
+
const result = Number(await resource.db.client.eval(REDIS_VERIFY_SCRIPT, 1, resource.key(id), emailKey, codeHash, String(maxFailedAttempts)));
|
|
80
|
+
if (result === 1)
|
|
81
|
+
return Outcome.Verified;
|
|
82
|
+
if (result === -1)
|
|
83
|
+
return Outcome.Invalid;
|
|
84
|
+
if (result === -2)
|
|
85
|
+
return Outcome.Exhausted;
|
|
86
|
+
return Outcome.Missing;
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const unavailable = new OtpUnavailable('challenge');
|
|
90
|
+
if (error instanceof Error)
|
|
91
|
+
unavailable.oiriginalStack = `${error}: ${error.stack}`;
|
|
92
|
+
throw unavailable;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
return service;
|
|
97
|
+
};
|
|
98
|
+
//# sourceMappingURL=challenge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"challenge.js","sourceRoot":"","sources":["../src/challenge.ts"],"names":[],"mappings":"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"}
|
package/build/consts.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export declare const SERVER_AUTH_OTP = "server-auth-otp";
|
|
2
|
+
export declare const OTP_CHALLENGE_STORE = "auth-otp-challenge-store";
|
|
3
|
+
export declare const OTP_THROTTLE_SERVICE = "auth-otp-throttle";
|
|
4
|
+
export declare const OTP_EMAIL_THROTTLE_RULES: readonly (Readonly<{
|
|
5
|
+
limit: 1;
|
|
6
|
+
windowSeconds: 60;
|
|
7
|
+
}> | Readonly<{
|
|
8
|
+
limit: 10;
|
|
9
|
+
windowSeconds: number;
|
|
10
|
+
}>)[];
|
|
2
11
|
/** The OTP auth plugin type key — passed as credential.type in init requests */
|
|
3
|
-
export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp';
|
|
12
|
+
export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH, OTP_MAX_FAILED_ATTEMPTS, } from '@owlmeans/auth-otp';
|
|
4
13
|
//# sourceMappingURL=consts.d.ts.map
|
package/build/consts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,eAAe,oBAAoB,CAAA;AAChD,eAAO,MAAM,mBAAmB,6BAA6B,CAAA;AAC7D,eAAO,MAAM,oBAAoB,sBAAsB,CAAA;AAEvD,eAAO,MAAM,wBAAwB;;;;;;KAGnC,CAAA;AAEF,gFAAgF;AAChF,OAAO,EACL,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAC1E,uBAAuB,GACxB,MAAM,oBAAoB,CAAA"}
|
package/build/consts.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export const SERVER_AUTH_OTP = 'server-auth-otp';
|
|
2
|
+
export const OTP_CHALLENGE_STORE = 'auth-otp-challenge-store';
|
|
3
|
+
export const OTP_THROTTLE_SERVICE = 'auth-otp-throttle';
|
|
4
|
+
export const OTP_EMAIL_THROTTLE_RULES = Object.freeze([
|
|
5
|
+
Object.freeze({ limit: 1, windowSeconds: 60 }),
|
|
6
|
+
Object.freeze({ limit: 10, windowSeconds: 60 * 60 }),
|
|
7
|
+
]);
|
|
2
8
|
/** The OTP auth plugin type key — passed as credential.type in init requests */
|
|
3
|
-
export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH } from '@owlmeans/auth-otp';
|
|
9
|
+
export { OTP_AUTH_TYPE, OTP_SERVICE, OTP_RESOURCE, OTP_TTL_SECONDS, OTP_CODE_LENGTH, OTP_MAX_FAILED_ATTEMPTS, } from '@owlmeans/auth-otp';
|
|
4
10
|
//# sourceMappingURL=consts.js.map
|
package/build/consts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAA;AAChD,MAAM,CAAC,MAAM,mBAAmB,GAAG,0BAA0B,CAAA;AAC7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,mBAAmB,CAAA;AAEvD,MAAM,CAAC,MAAM,wBAAwB,GAAG,MAAM,CAAC,MAAM,CAAC;IACpD,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;IAC9C,MAAM,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,aAAa,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC;CACrD,CAAC,CAAA;AAEF,gFAAgF;AAChF,OAAO,EACL,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAC1E,uBAAuB,GACxB,MAAM,oBAAoB,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ResilientError } from '@owlmeans/error';
|
|
2
|
+
export declare class OtpError extends ResilientError {
|
|
3
|
+
static typeName: string;
|
|
4
|
+
constructor(message?: string);
|
|
5
|
+
}
|
|
6
|
+
export declare class OtpThrottled extends OtpError {
|
|
7
|
+
static typeName: string;
|
|
8
|
+
static httpStatus: number;
|
|
9
|
+
readonly retryAfter: number;
|
|
10
|
+
constructor(retryAfter?: number | string, stack?: string);
|
|
11
|
+
}
|
|
12
|
+
export declare class OtpUnavailable extends OtpError {
|
|
13
|
+
static typeName: string;
|
|
14
|
+
static httpStatus: number;
|
|
15
|
+
static allowServerErrorStatus: boolean;
|
|
16
|
+
constructor(message?: string);
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,qBAAa,QAAS,SAAQ,cAAc;IAC1C,OAAuB,QAAQ,SAAa;IAE5C,YAAY,OAAO,GAAE,MAAgB,EAEpC;CACF;AAED,qBAAa,YAAa,SAAQ,QAAQ;IACxC,OAAuB,QAAQ,SAAkC;IACjE,OAAc,UAAU,SAAM;IAC9B,SAAgB,UAAU,EAAE,MAAM,CAAA;IAElC,YAAY,UAAU,GAAE,MAAM,GAAG,MAAU,EAAE,KAAK,CAAC,EAAE,MAAM,EAK1D;CACF;AAED,qBAAa,cAAe,SAAQ,QAAQ;IAC1C,OAAuB,QAAQ,SAAoC;IACnE,OAAc,UAAU,SAAM;IAC9B,OAAc,sBAAsB,UAAO;IAE3C,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF"}
|
package/build/errors.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ResilientError } from '@owlmeans/error';
|
|
2
|
+
export class OtpError extends ResilientError {
|
|
3
|
+
static typeName = 'OtpError';
|
|
4
|
+
constructor(message = 'error') {
|
|
5
|
+
super(OtpError.typeName, `otp:${message}`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class OtpThrottled extends OtpError {
|
|
9
|
+
static typeName = `${OtpError.typeName}Throttled`;
|
|
10
|
+
static httpStatus = 429;
|
|
11
|
+
retryAfter;
|
|
12
|
+
constructor(retryAfter = 1, stack) {
|
|
13
|
+
super(typeof retryAfter === 'string' ? retryAfter : 'throttled');
|
|
14
|
+
this.type = OtpThrottled.typeName;
|
|
15
|
+
this.retryAfter = typeof retryAfter === 'number' ? Math.max(1, Math.ceil(retryAfter)) : 1;
|
|
16
|
+
if (stack != null)
|
|
17
|
+
this.oiriginalStack = stack;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class OtpUnavailable extends OtpError {
|
|
21
|
+
static typeName = `${OtpError.typeName}Unavailable`;
|
|
22
|
+
static httpStatus = 503;
|
|
23
|
+
static allowServerErrorStatus = true;
|
|
24
|
+
constructor(message = 'store') {
|
|
25
|
+
super(`unavailable:${message}`);
|
|
26
|
+
this.type = OtpUnavailable.typeName;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
ResilientError.registerErrorClass(OtpError);
|
|
30
|
+
ResilientError.registerErrorClass(OtpThrottled);
|
|
31
|
+
ResilientError.registerErrorClass(OtpUnavailable);
|
|
32
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,OAAO,QAAS,SAAQ,cAAc;IACnC,MAAM,CAAU,QAAQ,GAAG,UAAU,CAAA;IAE5C,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,QAAQ,CAAC,QAAQ,EAAE,OAAO,OAAO,EAAE,CAAC,CAAA;IAC5C,CAAC;CACF;AAED,MAAM,OAAO,YAAa,SAAQ,QAAQ;IACjC,MAAM,CAAU,QAAQ,GAAG,GAAG,QAAQ,CAAC,QAAQ,WAAW,CAAA;IAC1D,MAAM,CAAC,UAAU,GAAG,GAAG,CAAA;IACd,UAAU,CAAQ;IAElC,YAAY,UAAU,GAAoB,CAAC,EAAE,KAAc;QACzD,KAAK,CAAC,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,WAAW,CAAC,CAAA;QAChE,IAAI,CAAC,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAA;QACjC,IAAI,CAAC,UAAU,GAAG,OAAO,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QACzF,IAAI,KAAK,IAAI,IAAI;YAAE,IAAI,CAAC,cAAc,GAAG,KAAK,CAAA;IAChD,CAAC;CACF;AAED,MAAM,OAAO,cAAe,SAAQ,QAAQ;IACnC,MAAM,CAAU,QAAQ,GAAG,GAAG,QAAQ,CAAC,QAAQ,aAAa,CAAA;IAC5D,MAAM,CAAC,UAAU,GAAG,GAAG,CAAA;IACvB,MAAM,CAAC,sBAAsB,GAAG,IAAI,CAAA;IAE3C,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,eAAe,OAAO,EAAE,CAAC,CAAA;QAC/B,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC,QAAQ,CAAA;IACrC,CAAC;CACF;AAED,cAAc,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAA;AAC3C,cAAc,CAAC,kBAAkB,CAAC,YAAY,CAAC,CAAA;AAC/C,cAAc,CAAC,kBAAkB,CAAC,cAAc,CAAC,CAAA"}
|
package/build/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export
|
|
1
|
+
export * from './types.js';
|
|
2
2
|
export * from './consts.js';
|
|
3
3
|
export * from './service.js';
|
|
4
4
|
export * from './plugin.js';
|
|
5
|
+
export * from './errors.js';
|
|
6
|
+
export * from './throttle.js';
|
|
7
|
+
export * from './challenge.js';
|
|
5
8
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA"}
|
package/build/index.js
CHANGED
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAA;AAC1B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA"}
|
package/build/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAqEvD,sGAAsG;AACtG,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,SAAS,EAAE,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,WAAW,CAAC,KAAG,CAG1F,CAAA"}
|
package/build/plugin.js
CHANGED
|
@@ -1,38 +1,36 @@
|
|
|
1
1
|
import { ALL_SCOPES, AuthenFailed, AuthRole, AuthenticationType } from '@owlmeans/auth';
|
|
2
|
-
import { registerPlugin } from '@owlmeans/server-auth/manager/plugins';
|
|
2
|
+
import { AuthChallengeReplayPolicy, registerPlugin } from '@owlmeans/server-auth/manager/plugins';
|
|
3
3
|
import { OTP_AUTH_TYPE, OTP_SERVICE } from '@owlmeans/auth-otp';
|
|
4
4
|
import { AUTH_IDENTITY_LINKING } from '@owlmeans/server-auth-identity';
|
|
5
|
-
|
|
6
|
-
// Separates the email from the per-request nonce inside the challenge (see init() below).
|
|
5
|
+
// Separates the email from the opaque issuance id inside the signed challenge.
|
|
7
6
|
const CHALLENGE_DELIMITER = '::';
|
|
8
7
|
/** Factory that creates the email-OTP AuthPlugin bound to the given context. */
|
|
9
8
|
const otpPlugin = (context) => ({
|
|
10
9
|
type: OTP_AUTH_TYPE,
|
|
10
|
+
challengeReplayPolicy: AuthChallengeReplayPolicy.Plugin,
|
|
11
11
|
init: async (request) => {
|
|
12
12
|
if (request.userId == null || !request.userId.includes('@')) {
|
|
13
13
|
throw new AuthenFailed('otp:email');
|
|
14
14
|
}
|
|
15
15
|
const email = request.userId.trim().toLowerCase();
|
|
16
16
|
const otp = context.service(OTP_SERVICE);
|
|
17
|
-
await otp.issueChallenge(email);
|
|
18
|
-
|
|
19
|
-
// must be unique per request — the plaintext email alone would be identical across every
|
|
20
|
-
// independent login attempt for that address and collide with a still-cached prior one.
|
|
21
|
-
const nonce = createIdOfLength(16, IdStyle.Base58);
|
|
22
|
-
return { challenge: `${email}${CHALLENGE_DELIMITER}${nonce}` };
|
|
17
|
+
const issuanceId = await otp.issueChallenge(email);
|
|
18
|
+
return { challenge: `${email}${CHALLENGE_DELIMITER}${issuanceId}` };
|
|
23
19
|
},
|
|
24
20
|
authenticate: async (credential) => {
|
|
25
|
-
// credential.challenge is "email::
|
|
26
|
-
const
|
|
21
|
+
// credential.challenge is "email::issuanceId" (opened from the signed envelope).
|
|
22
|
+
const parts = credential.challenge?.split(CHALLENGE_DELIMITER) ?? [];
|
|
23
|
+
const email = parts.length === 2 ? parts[0] : undefined;
|
|
24
|
+
const issuanceId = parts.length === 2 ? parts[1] : undefined;
|
|
27
25
|
const code = credential.credential;
|
|
28
|
-
if (!email || !email.includes('@')) {
|
|
26
|
+
if (!email || !email.includes('@') || !issuanceId) {
|
|
29
27
|
throw new AuthenFailed('otp:email');
|
|
30
28
|
}
|
|
31
29
|
if (!code) {
|
|
32
30
|
throw new AuthenFailed('otp:code');
|
|
33
31
|
}
|
|
34
32
|
const otp = context.service(OTP_SERVICE);
|
|
35
|
-
await otp.verifyChallenge(email, code);
|
|
33
|
+
await otp.verifyChallenge(email, issuanceId, code);
|
|
36
34
|
const identityAlias = context.cfg.otp?.identityAlias ?? AUTH_IDENTITY_LINKING;
|
|
37
35
|
const identity = context.service(identityAlias);
|
|
38
36
|
// What arrives on the credential is the organization SLUG the user is logging into — it is
|
package/build/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,QAAQ,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,uCAAuC,CAAA;
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
|
package/build/service.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { OtpService } from '@owlmeans/auth-otp';
|
|
2
|
+
export declare const otpChallengeKey: (issuanceId: string) => string;
|
|
3
|
+
export declare const otpEmailKey: (email: string) => string;
|
|
4
|
+
export declare const otpCodeHash: (issuanceId: string, code: string) => string;
|
|
2
5
|
export declare const makeOtpService: (alias?: string) => OtpService;
|
|
3
6
|
//# sourceMappingURL=service.d.ts.map
|
package/build/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAkBpD,eAAO,MAAM,eAAe,eAAgB,MAAM,KAAG,MAAuD,CAAA;AAC5G,eAAO,MAAM,WAAW,UAAW,MAAM,KAAG,MAAqD,CAAA;AACjG,eAAO,MAAM,WAAW,eAAgB,MAAM,QAAQ,MAAM,KAAG,MACd,CAAA;AAEjD,eAAO,MAAM,cAAc,sBAA0B,UAiEpD,CAAA"}
|
package/build/service.js
CHANGED
|
@@ -1,51 +1,74 @@
|
|
|
1
1
|
import { appendContextual } from '@owlmeans/context';
|
|
2
2
|
import { AuthenFailed } from '@owlmeans/auth';
|
|
3
|
-
import { OTP_SERVICE,
|
|
3
|
+
import { OTP_SERVICE, OTP_TTL_SECONDS, OTP_CODE_LENGTH, OTP_MAX_FAILED_ATTEMPTS, OTP_RESOURCE, } from '@owlmeans/auth-otp';
|
|
4
4
|
import { MAILER_SERVICE } from '@owlmeans/mailer';
|
|
5
|
-
|
|
5
|
+
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
|
6
|
+
import { OtpChallengeOutcome } from './types.js';
|
|
7
|
+
import { OTP_CHALLENGE_STORE } from './consts.js';
|
|
8
|
+
import { emailThrottleKey } from './throttle.js';
|
|
9
|
+
import { OtpThrottled, OtpUnavailable } from './errors.js';
|
|
10
|
+
import { makeRedisOtpChallengeStore } from './challenge.js';
|
|
11
|
+
const digest = (scope, value) => createHash('sha256').update(`owlmeans:otp:${scope}\0${value}`).digest('hex');
|
|
12
|
+
export const otpChallengeKey = (issuanceId) => `challenge:${digest('issuance', issuanceId)}`;
|
|
13
|
+
export const otpEmailKey = (email) => digest('email', email.trim().toLowerCase());
|
|
14
|
+
export const otpCodeHash = (issuanceId, code) => digest('code', `${issuanceId}\0${code.trim()}`);
|
|
6
15
|
export const makeOtpService = (alias = OTP_SERVICE) => {
|
|
16
|
+
let fallbackChallenges;
|
|
17
|
+
const challengeStore = (ctx) => {
|
|
18
|
+
const challengeAlias = ctx.cfg.otp?.challengeStoreAlias ?? OTP_CHALLENGE_STORE;
|
|
19
|
+
if (ctx.hasService(challengeAlias))
|
|
20
|
+
return ctx.service(challengeAlias);
|
|
21
|
+
// Backwards-compatible secure default: existing consumers that registered only OTP_RESOURCE
|
|
22
|
+
// still get the atomic Redis store. New consumers can inject the real memory implementation.
|
|
23
|
+
if (fallbackChallenges == null) {
|
|
24
|
+
fallbackChallenges = makeRedisOtpChallengeStore(`${alias}:redis-challenge`, ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE);
|
|
25
|
+
fallbackChallenges.registerContext(ctx);
|
|
26
|
+
}
|
|
27
|
+
return fallbackChallenges;
|
|
28
|
+
};
|
|
7
29
|
const service = appendContextual(alias, {
|
|
8
30
|
issueChallenge: async (email) => {
|
|
9
31
|
const ctx = service.ctx;
|
|
10
|
-
const resourceAlias = ctx.cfg.otp?.resourceAlias ?? OTP_RESOURCE;
|
|
11
32
|
const mailerAlias = ctx.cfg.otp?.mailerAlias ?? MAILER_SERVICE;
|
|
12
|
-
const
|
|
33
|
+
const challenges = challengeStore(ctx);
|
|
13
34
|
const mailer = ctx.service(mailerAlias);
|
|
14
35
|
const code = generateCode();
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
36
|
+
const issuanceId = randomBytes(18).toString('base64url');
|
|
37
|
+
const throttleAlias = ctx.cfg.otp?.throttleAlias;
|
|
38
|
+
const throttleRules = ctx.cfg.otp?.throttleRules;
|
|
39
|
+
if (throttleAlias != null && throttleRules != null) {
|
|
40
|
+
const decision = await ctx.service(throttleAlias)
|
|
41
|
+
.consume(emailThrottleKey(email), throttleRules, issuanceId);
|
|
42
|
+
if (!decision.allowed)
|
|
43
|
+
throw new OtpThrottled(decision.retryAfter);
|
|
19
44
|
}
|
|
20
|
-
|
|
21
|
-
|
|
45
|
+
const issued = await challenges.issue({
|
|
46
|
+
id: otpChallengeKey(issuanceId),
|
|
47
|
+
emailKey: otpEmailKey(email),
|
|
48
|
+
codeHash: otpCodeHash(issuanceId, code),
|
|
49
|
+
}, OTP_TTL_SECONDS);
|
|
50
|
+
if (!issued)
|
|
51
|
+
throw new OtpUnavailable('issuance-collision');
|
|
22
52
|
await mailer.send({
|
|
23
53
|
to: email,
|
|
24
54
|
subject: 'Your login code',
|
|
25
55
|
text: `Your one-time login code is: ${code}\n\nIt expires in ${OTP_TTL_SECONDS / 60} minutes.`,
|
|
26
56
|
html: `<p>Your one-time login code is: <strong>${code}</strong></p><p>It expires in ${OTP_TTL_SECONDS / 60} minutes.</p>`,
|
|
27
57
|
});
|
|
58
|
+
return issuanceId;
|
|
28
59
|
},
|
|
29
|
-
verifyChallenge: async (email, code) => {
|
|
60
|
+
verifyChallenge: async (email, issuanceId, code) => {
|
|
30
61
|
const ctx = service.ctx;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const id = codeKey(email);
|
|
34
|
-
const record = await resource.load(id);
|
|
35
|
-
if (record == null || record.code !== code.trim()) {
|
|
62
|
+
const outcome = await challengeStore(ctx).verify(otpChallengeKey(issuanceId), otpEmailKey(email), otpCodeHash(issuanceId, code), OTP_MAX_FAILED_ATTEMPTS);
|
|
63
|
+
if (outcome !== OtpChallengeOutcome.Verified) {
|
|
36
64
|
throw new AuthenFailed('otp:code');
|
|
37
65
|
}
|
|
38
|
-
// Consume: delete so the code cannot be reused.
|
|
39
|
-
try {
|
|
40
|
-
await resource.delete(id);
|
|
41
|
-
}
|
|
42
|
-
catch { /* already expired */ }
|
|
43
66
|
},
|
|
44
67
|
});
|
|
45
68
|
return service;
|
|
46
69
|
};
|
|
47
70
|
const generateCode = () => {
|
|
48
|
-
const digits =
|
|
71
|
+
const digits = randomInt(0, Math.pow(10, OTP_CODE_LENGTH));
|
|
49
72
|
return String(digits).padStart(OTP_CODE_LENGTH, '0');
|
|
50
73
|
};
|
|
51
74
|
//# sourceMappingURL=service.js.map
|
package/build/service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE7C,OAAO,
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuthThrottleService } from './types.js';
|
|
2
|
+
type Clock = () => number;
|
|
3
|
+
/** Collision-resistant, non-raw key for case-insensitive email throttles. */
|
|
4
|
+
export declare const emailThrottleKey: (email: string) => string;
|
|
5
|
+
/** Non-raw key for request-source throttles. Callers remain responsible for trusted-proxy parsing. */
|
|
6
|
+
export declare const ipThrottleKey: (ip: string) => string;
|
|
7
|
+
/** Real in-memory implementation for single-process development and deterministic unit tests. */
|
|
8
|
+
export declare const makeMemoryThrottleService: (alias?: string, now?: Clock) => AuthThrottleService;
|
|
9
|
+
/** Redis implementation: every configured window is checked and consumed in one Lua operation. */
|
|
10
|
+
export declare const makeRedisThrottleService: (alias?: string, resourceAlias?: string) => AuthThrottleService;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=throttle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"throttle.d.ts","sourceRoot":"","sources":["../src/throttle.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,mBAAmB,EAAyD,MAAM,YAAY,CAAA;AAG5G,KAAK,KAAK,GAAG,MAAM,MAAM,CAAA;AAKzB,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,UAAW,MAAM,KAAG,MACU,CAAA;AAE3D,sGAAsG;AACtG,eAAO,MAAM,aAAa,OAAQ,MAAM,KAAG,MACO,CAAA;AASlD,iGAAiG;AACjG,eAAO,MAAM,yBAAyB,yBACD,KAAK,KACvC,mBAyBF,CAAA;AA+BD,kGAAkG;AAClG,eAAO,MAAM,wBAAwB,8CAElC,mBA4BF,CAAA"}
|