@owlmeans/server-auth-otp 0.1.18-rc.54 → 0.1.18-rc.56

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 CHANGED
@@ -1,43 +1,175 @@
1
1
  # @owlmeans/server-auth-otp
2
2
 
3
- Email OTP `AuthPlugin` for OwlMeans servers — passwordless login via time-limited one-time codes.
4
-
5
- ## Overview
6
-
7
- - `appendOtpPlugin(context)` — registers the `AuthPlugin` that handles `OTP_AUTH_TYPE` (`'email-otp'`) authentication requests
8
- - `makeOtpService(alias?)` — issues email challenges and verifies submitted codes; backed by `@owlmeans/auth-otp`
9
- - Integrates with the `@owlmeans/server-auth` plugin registry — plugs in alongside other auth strategies
10
- - Resolves identities through `@owlmeans/server-auth-identity` after OTP verification
3
+ Email one-time-code login for an OwlMeans server: the `OtpService` implementation that issues and
4
+ verifies codes, the `'email-otp'` `AuthPlugin` for the `@owlmeans/server-auth` plugin registry,
5
+ atomic challenge stores and sliding-window throttles (Redis for production, memory for development
6
+ and tests), and the OTP errors. A server uses it to offer passwordless sign-in by e-mail; the
7
+ verified address is resolved to an account through `@owlmeans/server-auth-identity`. It does not send
8
+ mail itself — a `MailerService` must be registered — and it is not the place for shared constants or
9
+ the `OtpService` type: depend on `@owlmeans/auth-otp` from a package that only names the auth type or
10
+ types the service, and on this package only where the server wires itself up.
11
11
 
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/server-auth-otp@^0.1.18-rc.54
15
+ bun add @owlmeans/server-auth-otp@^0.1.18-rc.56
16
16
  ```
17
17
 
18
+ ## Concepts
19
+
20
+ - **Issuance** — `issueChallenge(email)` creates a random 6-digit code (`OTP_CODE_LENGTH`), stores it
21
+ for `OTP_TTL_SECONDS` (600 s) under an opaque issuance id, mails it and returns the id. The id
22
+ never contains the address or the code.
23
+ - **Challenge store** — `OtpChallengeStore`, keyed by digests from `otpKeyHelper`. `verify`
24
+ compares, counts failures and consumes in one operation; after `OTP_MAX_FAILED_ATTEMPTS` (5) the
25
+ issuance is exhausted.
26
+ - **Throttle** — `AuthThrottleService.consume(key, rules, token?)` applies every `ThrottleRule`
27
+ (`{ limit, windowSeconds }`) together. Enabled only when `cfg.otp.throttleAlias` and
28
+ `cfg.otp.throttleRules` are both set.
29
+ - **Plugin** — `appendOtpPlugin` registers the plugin factory under `OTP_AUTH_TYPE` in the
30
+ server-auth registry. `init` issues a challenge `"<email>::<issuanceId>"`; `authenticate`
31
+ verifies the code, links or finds the profile and fills the credential
32
+ (`AuthenticationType.OneTimeToken`).
33
+
18
34
  ## Usage
19
35
 
36
+ ### Wire the server
37
+
20
38
  ```typescript
21
- import { makeRedisResource } from '@owlmeans/redis-resource'
22
- import { OTP_RESOURCE, OTP_SERVICE } from '@owlmeans/auth-otp'
23
- import { appendOtpPlugin, makeOtpService } from '@owlmeans/server-auth-otp'
24
39
  import { MAILER_SERVICE } from '@owlmeans/mailer'
25
- import { makeSmtpMailerService } from '@owlmeans/mailer-smtp'
40
+ import { makeMailgunMailerService } from '@owlmeans/server-mailer-mailgun'
41
+ import { makeRedisResource } from '@owlmeans/redis-resource'
42
+ import {
43
+ OTP_RESOURCE, appendOtpPlugin, makeOtpService, makeRedisOtpChallengeStore, makeRedisThrottleService,
44
+ } from '@owlmeans/server-auth-otp'
45
+
46
+ context.registerResource(makeRedisResource(OTP_RESOURCE)) // challenge and throttle storage
47
+ context.registerService(makeRedisOtpChallengeStore()) // OTP_CHALLENGE_STORE
48
+ context.registerService(makeRedisThrottleService()) // OTP_THROTTLE_SERVICE
49
+ context.registerService(makeMailgunMailerService(MAILER_SERVICE)) // delivers the code
50
+ context.registerService(makeOtpService()) // OTP_SERVICE
51
+
52
+ appendOtpPlugin(context) // plugin under 'email-otp'
53
+ ```
54
+
55
+ ### Configure
56
+
57
+ ```typescript
58
+ import { OTP_EMAIL_THROTTLE_RULES, OTP_THROTTLE_SERVICE } from '@owlmeans/server-auth-otp'
59
+ import type { OtpConfig } from '@owlmeans/server-auth-otp'
60
+
61
+ const otp: OtpConfig['otp'] = {
62
+ throttleAlias: OTP_THROTTLE_SERVICE,
63
+ throttleRules: OTP_EMAIL_THROTTLE_RULES, // 1 per minute, 10 per hour
64
+ // mailerAlias, resourceAlias, challengeStoreAlias, identityAlias override the defaults
65
+ }
66
+ ```
67
+
68
+ ### Test with in-memory stores and the console mailer
69
+
70
+ ```typescript
71
+ import type { OtpService } from '@owlmeans/auth-otp'
72
+ import { makeDefaultConsoleMailerService } from '@owlmeans/mailer'
73
+ import {
74
+ OTP_SERVICE, makeMemoryOtpChallengeStore, makeMemoryThrottleService, makeOtpService,
75
+ } from '@owlmeans/server-auth-otp'
26
76
 
27
- context.registerResource(makeRedisResource(OTP_RESOURCE)) // challenge store
28
- context.registerService(makeSmtpMailerService(MAILER_SERVICE))
29
- context.registerService(makeOtpService(OTP_SERVICE))
77
+ const mailer = makeDefaultConsoleMailerService()
78
+ context.registerService(mailer)
79
+ context.registerService(makeMemoryOtpChallengeStore())
80
+ context.registerService(makeMemoryThrottleService())
81
+ context.registerService(makeOtpService())
82
+ await context.configure().init()
30
83
 
31
- appendOtpPlugin(context) // registers the plugin factory under 'email-otp'
84
+ const otp = context.service<OtpService>(OTP_SERVICE)
85
+ const issuanceId = await otp.issueChallenge('user@example.com')
86
+ const code = mailer.captured[0].text?.match(/\d{6}/)?.[0] ?? ''
87
+ await otp.verifyChallenge('user@example.com', issuanceId, code) // throws AuthenFailed('otp:code') on a mismatch
32
88
  ```
33
89
 
34
- `appendOtpPlugin` writes into a module-level registry, so it is a global side effect: the
35
- `context` it takes is only returned for chaining, and the plugin factory receives the live
36
- context per request.
90
+ `OtpService` and `OTP_SERVICE` come from `@owlmeans/auth-otp` (the constant is also re-exported
91
+ here). The memory factories accept a clock as their second argument for deterministic tests.
92
+
93
+ ## API
94
+
95
+ ### Services and plugin
96
+
97
+ | Symbol | Purpose |
98
+ |---|---|
99
+ | `makeOtpService(alias = OTP_SERVICE)` | `OtpService`: `issueChallenge(email) → issuanceId`, `verifyChallenge(email, issuanceId, code)` |
100
+ | `appendOtpPlugin(context)` | registers the `'email-otp'` plugin factory; returns the context |
101
+ | `makeRedisOtpChallengeStore(alias?, resourceAlias?)` | atomic Redis store (`SET NX EX` + one Lua verify) |
102
+ | `makeMemoryOtpChallengeStore(alias?, now?)` | in-memory store for a single process or tests |
103
+ | `makeRedisThrottleService(alias?, resourceAlias?)` | every window checked and consumed in one Lua call |
104
+ | `makeMemoryThrottleService(alias?, now?)` | in-memory sliding-window throttle |
105
+ | `otpKeyHelper` (`createOtpKeyHelper()`) | `otpChallengeKey`, `otpEmailKey`, `otpCodeHash` — SHA-256 digests |
106
+ | `throttleKeyHelper` (`createThrottleKeyHelper()`) | `emailThrottleKey`, `ipThrottleKey` — digests, never raw values |
107
+ | `emailThrottleKey`, `ipThrottleKey` | deprecated standalone forms of the helper members |
108
+
109
+ ### Constants
110
+
111
+ | Symbol | Value |
112
+ |---|---|
113
+ | `SERVER_AUTH_OTP` | `'server-auth-otp'` |
114
+ | `OTP_CHALLENGE_STORE` | `'auth-otp-challenge-store'` |
115
+ | `OTP_THROTTLE_SERVICE` | `'auth-otp-throttle'` |
116
+ | `OTP_EMAIL_THROTTLE_RULES` | `[{ limit: 1, windowSeconds: 60 }, { limit: 10, windowSeconds: 3600 }]` |
117
+ | `OtpChallengeOutcome` | `Verified`, `Invalid`, `Exhausted`, `Missing` |
118
+ | re-exported from `@owlmeans/auth-otp` | `OTP_AUTH_TYPE` (`'email-otp'`), `OTP_SERVICE`, `OTP_RESOURCE` (`'auth-otp-cache'`), `OTP_TTL_SECONDS`, `OTP_CODE_LENGTH`, `OTP_MAX_FAILED_ATTEMPTS` |
119
+
120
+ ### Types
121
+
122
+ `OtpConfig` (`cfg.otp`: `mailerAlias?`, `resourceAlias?`, `challengeStoreAlias?`, `throttleAlias?`,
123
+ `throttleRules?`, `identityAlias?`), `OtpContext`, `OtpChallenge`, `OtpChallengeStore`,
124
+ `AuthThrottleService`, `ThrottleRule`, `ThrottleDecision`, `OtpKeyHelper`, `ThrottleKeyHelper`.
125
+
126
+ ### Errors
127
+
128
+ | Class | Status | Meaning |
129
+ |---|---|---|
130
+ | `OtpError` | — | base, message `otp:<reason>` |
131
+ | `OtpThrottled` | 429 | issuance refused by the throttle; `retryAfter` in seconds |
132
+ | `OtpUnavailable` | 503 | the store or throttle backend failed, or an issuance id collided |
133
+
134
+ A wrong, expired or exhausted code is `AuthenFailed('otp:code')` from `@owlmeans/auth`; a missing or
135
+ malformed address is `AuthenFailed('otp:email')`.
136
+
137
+ ## Auth flow
138
+
139
+ 1. **Init** — the client sends `{ type: 'email-otp', userId: <email> }`; the plugin lowercases the
140
+ address, issues a code and returns the challenge in the signed envelope.
141
+ 2. **Authenticate** — the client sends the envelope as `challenge`, the code as `credential`, plus
142
+ `type`, `role` and `scopes` (required by `AuthCredentialsSchema`). The plugin verifies the
143
+ issuance, resolves the profile through the `IdentityLinkingService` under
144
+ `cfg.otp.identityAlias ?? AUTH_IDENTITY_LINKING` (linking a new one when absent) and copies
145
+ `userId`, `profileId`, `entitySlug`, `role` and `scopes` from it onto the credential.
146
+
147
+ ## Common pitfalls
148
+
149
+ - `makeConsoleMailerService()` registers under `'console-mailer'`; the OTP service resolves
150
+ `cfg.otp.mailerAlias ?? MAILER_SERVICE`. Use `makeDefaultConsoleMailerService()` in tests.
151
+ - Without a registered challenge store the service falls back to a Redis store over `OTP_RESOURCE`;
152
+ register that resource or an explicit store.
153
+ - Throttling is off unless both `throttleAlias` and `throttleRules` are configured.
154
+ - `appendOtpPlugin` writes into a module-level registry — call it once; the context argument is only
155
+ returned for chaining.
156
+ - `credential.entitySlug` on the authenticate request selects nothing: the linked payload's own slug
157
+ overwrites it, so the address alone decides the account and organization entity.
158
+ - The resulting auth token can exceed 1024 characters; a route accepting it must size its schema
159
+ beyond the generic `AuthTokenSchema`.
160
+ - Never encode the address or code into the issuance id, and never replace the attempt counter with
161
+ a bare replay key.
162
+
163
+ ## Related packages
164
+
165
+ - [`@owlmeans/auth-otp`](../auth-otp) — `OtpService` type and the shared OTP constants
166
+ - [`@owlmeans/server-auth`](../server-auth) — the auth-manager plugin registry this plugin joins
167
+ - [`@owlmeans/server-auth-identity`](../server-auth-identity) — `IdentityLinkingService`, which maps the address to an account
168
+ - [`@owlmeans/mailer`](../mailer) — `MailerService` and the console transport for tests
169
+ - [`@owlmeans/server-mailer-mailgun`](../server-mailer-mailgun) / [`@owlmeans/mailer-smtp`](../mailer-smtp) — production transports
170
+ - [`@owlmeans/redis-resource`](../redis-resource) — the Redis resource behind the stores and throttle
37
171
 
38
- The OTP service must be backed by a transport that can deliver the one-time code —
39
- `@owlmeans/mailer-smtp` (SMTP) or `@owlmeans/server-mailer-mailgun` (Mailgun HTTP API),
40
- registered under `MAILER_SERVICE`. Override the alias with `cfg.otp.mailerAlias`.
172
+ The `server-auth-otp` skill covers the flow and its schema gotchas for agents.
41
173
 
42
174
  <!-- owlmeans:agent-guidance:start -->
43
175
  ## Agent guidance
@@ -47,7 +179,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
47
179
  your project's skill store (`.agents/skills/`):
48
180
 
49
181
  ```sh
50
- npx @owlmeans/agent-skills@^0.1.18-rc.51
182
+ npx @owlmeans/agent-skills@^0.1.18-rc.53
51
183
  ```
52
184
 
53
185
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-auth-otp",
4
- "version": "0.1.18-rc.54",
5
- "generatedAt": "2026-10-06T11:45:57.663Z",
4
+ "version": "0.1.18-rc.56",
5
+ "generatedAt": "2026-10-09T11:30:42.746Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # Using `@owlmeans/server-auth-otp`
10
10
 
11
- **Install:** `"@owlmeans/server-auth-otp": "^0.1.18-rc.54"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-auth-otp": "^0.1.18-rc.56"` 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-auth-otp",
3
- "version": "0.1.18-rc.54",
3
+ "version": "0.1.18-rc.56",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,18 +22,18 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/auth": "^0.1.18-rc.40",
26
- "@owlmeans/auth-otp": "^0.1.18-rc.34",
25
+ "@owlmeans/auth": "^0.1.18-rc.41",
26
+ "@owlmeans/auth-otp": "^0.1.18-rc.35",
27
27
  "@owlmeans/context": "^0.1.18-rc.34",
28
28
  "@owlmeans/error": "^0.1.18-rc.38",
29
- "@owlmeans/log": "^0.1.18-rc.2",
30
- "@owlmeans/mailer": "^0.1.18-rc.36",
31
- "@owlmeans/oidc": "^0.1.18-rc.56",
32
- "@owlmeans/redis-resource": "^0.1.18-rc.45",
29
+ "@owlmeans/log": "^0.1.18-rc.3",
30
+ "@owlmeans/mailer": "^0.1.18-rc.37",
31
+ "@owlmeans/oidc": "^0.1.18-rc.57",
32
+ "@owlmeans/redis-resource": "^0.1.18-rc.46",
33
33
  "@owlmeans/resource": "^0.1.18-rc.40",
34
- "@owlmeans/server-auth": "^0.1.18-rc.55",
35
- "@owlmeans/server-auth-identity": "^0.1.18-rc.48",
36
- "@owlmeans/server-context": "^0.1.18-rc.43"
34
+ "@owlmeans/server-auth": "^0.1.18-rc.57",
35
+ "@owlmeans/server-auth-identity": "^0.1.18-rc.49",
36
+ "@owlmeans/server-context": "^0.1.18-rc.44"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@owlmeans/dep-config": "workspace:*",