@owlmeans/auth-common 0.1.18-rc.29 → 0.1.18-rc.30
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/auth-common/SKILL.md +5 -2
- package/build/guards/basic-ed25519/index.d.ts +1 -0
- package/build/guards/basic-ed25519/index.d.ts.map +1 -1
- package/build/guards/basic-ed25519/index.js +1 -0
- package/build/guards/basic-ed25519/index.js.map +1 -1
- package/build/guards/basic-ed25519/replay.d.ts +14 -0
- package/build/guards/basic-ed25519/replay.d.ts.map +1 -0
- package/build/guards/basic-ed25519/replay.js +49 -0
- package/build/guards/basic-ed25519/replay.js.map +1 -0
- package/build/guards/basic-ed25519/service.d.ts +0 -5
- package/build/guards/basic-ed25519/service.d.ts.map +1 -1
- package/build/guards/basic-ed25519/service.js +32 -17
- package/build/guards/basic-ed25519/service.js.map +1 -1
- package/build/guards/basic-ed25519/types.d.ts +16 -0
- package/build/guards/basic-ed25519/types.d.ts.map +1 -1
- package/package.json +11 -11
- package/src/guards/basic-ed25519/index.ts +1 -0
- package/src/guards/basic-ed25519/replay.ts +55 -0
- package/src/guards/basic-ed25519/service.ts +42 -18
- package/src/guards/basic-ed25519/types.ts +17 -0
- package/tests/basic-ed25519.spec.ts +99 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Predefined authentication protocols, guards, and constants shared between server
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
bun add @owlmeans/auth-common@^0.1.18-rc.
|
|
15
|
+
bun add @owlmeans/auth-common@^0.1.18-rc.30
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
@@ -98,7 +98,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
98
98
|
your project's skill store (`.agents/skills/`):
|
|
99
99
|
|
|
100
100
|
```sh
|
|
101
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
101
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.30
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
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/auth-common",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.30",
|
|
5
|
+
"generatedAt": "2026-09-19T13:42:56.242Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/auth-common
|
|
9
9
|
|
|
10
10
|
**Layer:** Core
|
|
11
|
-
**Install:** `"@owlmeans/auth-common": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/auth-common": "^0.1.18-rc.30"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
Everything a server and a browser must agree on to talk authentication: aliases, the shared
|
|
14
14
|
protocol declarations, the signature guard, and the contract for resolving the organization
|
|
@@ -128,7 +128,10 @@ depends on the value.
|
|
|
128
128
|
the matching client-side alias as `DEFAULT_ALIAS`.
|
|
129
129
|
- `GUARD_ED25519` is for service-to-service calls, where the caller signs the request rather than
|
|
130
130
|
presenting a bearer token. Give it a Redis-backed nonce cache (`BED255_CASHE_RESOURCE`) in any
|
|
131
|
-
deployment that runs more than one replica, or replay protection is per-process only.
|
|
131
|
+
deployment that runs more than one replica, or replay protection is per-process only. The guard
|
|
132
|
+
accepts only canonical timestamps inside its symmetric 60-second window and claims the
|
|
133
|
+
credential-and-nonce pair until that timestamp's absolute expiry; a replay-store error refuses
|
|
134
|
+
authentication rather than disabling deduplication.
|
|
132
135
|
- An OIDC gate is not required when OIDC is only the login/bootstrap provider. Where the provider
|
|
133
136
|
issues a local bearer token, authorize with a product-specific gate against local identity scopes.
|
|
134
137
|
- The trust resource (`TRUSTED`, from `@owlmeans/config`) is the system's source of truth for known
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SignedRequestReplayResource, SignedRequestReplayStore } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Process-local replay protection used when a deployment has not supplied a shared resource.
|
|
4
|
+
* A timer removes each claim at its signed expiry, keeping the fallback bounded by the live
|
|
5
|
+
* replay window rather than by process lifetime.
|
|
6
|
+
*/
|
|
7
|
+
export declare const makeMemorySignedRequestReplayStore: () => SignedRequestReplayStore;
|
|
8
|
+
/**
|
|
9
|
+
* Adapt a create-once Resource to signed-request replay protection. Redis resources honour the
|
|
10
|
+
* absolute expiry and make the claim atomic across replicas (`SET NX`); other resource failures
|
|
11
|
+
* remain failures rather than being mistaken for successful claims.
|
|
12
|
+
*/
|
|
13
|
+
export declare const makeResourceSignedRequestReplayStore: (resource: SignedRequestReplayResource) => SignedRequestReplayStore;
|
|
14
|
+
//# sourceMappingURL=replay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/replay.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,2BAA2B,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAEvF;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,QAAO,wBAwBrD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oCAAoC,aACrC,2BAA2B,KACpC,wBAaD,CAAA"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RecordExists } from '@owlmeans/resource';
|
|
2
|
+
/**
|
|
3
|
+
* Process-local replay protection used when a deployment has not supplied a shared resource.
|
|
4
|
+
* A timer removes each claim at its signed expiry, keeping the fallback bounded by the live
|
|
5
|
+
* replay window rather than by process lifetime.
|
|
6
|
+
*/
|
|
7
|
+
export const makeMemorySignedRequestReplayStore = () => {
|
|
8
|
+
const claims = new Map();
|
|
9
|
+
return {
|
|
10
|
+
claim: async (id, expiresAt) => {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const current = claims.get(id);
|
|
13
|
+
if (current != null && current > now) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const expiry = expiresAt.getTime();
|
|
17
|
+
claims.set(id, expiry);
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
if (claims.get(id) === expiry) {
|
|
20
|
+
claims.delete(id);
|
|
21
|
+
}
|
|
22
|
+
}, Math.max(0, expiry - now));
|
|
23
|
+
const unref = timer.unref;
|
|
24
|
+
unref?.call(timer);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Adapt a create-once Resource to signed-request replay protection. Redis resources honour the
|
|
31
|
+
* absolute expiry and make the claim atomic across replicas (`SET NX`); other resource failures
|
|
32
|
+
* remain failures rather than being mistaken for successful claims.
|
|
33
|
+
*/
|
|
34
|
+
export const makeResourceSignedRequestReplayStore = (resource) => ({
|
|
35
|
+
claim: async (id, expiresAt) => {
|
|
36
|
+
try {
|
|
37
|
+
await resource.create({ id }, { ttl: expiresAt });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof RecordExists
|
|
42
|
+
|| error?.type === RecordExists.typeName) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=replay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replay.js","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/replay.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAGjD;;;;GAIG;AACH,MAAM,CAAC,MAAM,kCAAkC,GAAG,GAA6B,EAAE;IAC/E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;IAExC,OAAO;QACL,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE;YAC7B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;YACtB,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;YAC9B,IAAI,OAAO,IAAI,IAAI,IAAI,OAAO,GAAG,GAAG,EAAE,CAAC;gBACrC,OAAO,KAAK,CAAA;YACd,CAAC;YAED,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,EAAE,CAAA;YAClC,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC,CAAA;YACtB,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBAC5B,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC;oBAC9B,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;gBACnB,CAAC;YACH,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,CAAC,CAAA;YAC7B,MAAM,KAAK,GAAI,KAA2C,CAAC,KAAK,CAAA;YAChE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,CAAA;YAElB,OAAO,IAAI,CAAA;QACb,CAAC;KACF,CAAA;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,oCAAoC,GAAG,CAClD,QAAqC,EACX,EAAE,CAAC,CAAC;IAC9B,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE;QAC7B,IAAI,CAAC;YACH,MAAM,QAAQ,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAA;YACjD,OAAO,IAAI,CAAA;QACb,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,YAAY;mBAC3B,KAAmC,EAAE,IAAI,KAAK,YAAY,CAAC,QAAQ,EAAE,CAAC;gBAC1E,OAAO,KAAK,CAAA;YACd,CAAC;YACD,MAAM,KAAK,CAAA;QACb,CAAC;IACH,CAAC;CACF,CAAC,CAAA"}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import type { BasicEd25519Guard, BasicEd25519GuardOptions } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Proper configuration:
|
|
4
|
-
* - One needs to specify opts.cache as a redis resource that stores nonces for a while to
|
|
5
|
-
* avoid requests duplication.
|
|
6
|
-
*/
|
|
7
2
|
export declare const makeBasicEd25519Guard: (resource: string, opts?: BasicEd25519GuardOptions) => BasicEd25519Guard;
|
|
8
3
|
//# sourceMappingURL=service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/service.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/service.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAA;AAa7E,eAAO,MAAM,qBAAqB,aAAc,MAAM,SAAS,wBAAwB,KAAG,iBA+IzF,CAAA"}
|
|
@@ -5,17 +5,21 @@ import { trust } from '../../utils/trusted.js';
|
|
|
5
5
|
import { AuthenPayloadError, AuthroizationType, AuthRole } from '@owlmeans/auth';
|
|
6
6
|
import { createIdOfLength } from '@owlmeans/basic-ids';
|
|
7
7
|
import { extractAuthToken } from '@owlmeans/auth-common/utils';
|
|
8
|
+
import { makeMemorySignedRequestReplayStore, makeResourceSignedRequestReplayStore } from './replay.js';
|
|
8
9
|
const timeKey = BED255_TIME_HEADER.toLocaleLowerCase();
|
|
9
10
|
const nonceKey = BED255_NONCE_HEADER.toLocaleLowerCase();
|
|
10
|
-
/**
|
|
11
|
-
* Proper configuration:
|
|
12
|
-
* - One needs to specify opts.cache as a redis resource that stores nonces for a while to
|
|
13
|
-
* avoid requests duplication.
|
|
14
|
-
*/
|
|
15
11
|
export const makeBasicEd25519Guard = (resource, opts) => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
const memoryReplay = makeMemorySignedRequestReplayStore();
|
|
13
|
+
const replay = (context) => {
|
|
14
|
+
if (opts?.replay != null) {
|
|
15
|
+
return opts.replay;
|
|
16
|
+
}
|
|
17
|
+
const alias = opts?.cache ?? BED255_CASHE_RESOURCE;
|
|
18
|
+
if (context.hasResource(alias)) {
|
|
19
|
+
return makeResourceSignedRequestReplayStore(context.resource(alias));
|
|
20
|
+
}
|
|
21
|
+
return memoryReplay;
|
|
22
|
+
};
|
|
19
23
|
const guard = createService(GUARD_ED25519, {
|
|
20
24
|
authenticated: async (req) => {
|
|
21
25
|
const context = assertContext(guard.ctx);
|
|
@@ -54,32 +58,43 @@ export const makeBasicEd25519Guard = (resource, opts) => {
|
|
|
54
58
|
const [key, value] = pair.split('=', 2);
|
|
55
59
|
return { ...result, [key.toLowerCase()]: value };
|
|
56
60
|
}, {});
|
|
61
|
+
if (typeof signature.credential !== 'string' || signature.credential.length === 0
|
|
62
|
+
|| typeof signature.signature !== 'string' || signature.signature.length === 0) {
|
|
63
|
+
throw new AuthenPayloadError('signature');
|
|
64
|
+
}
|
|
57
65
|
const trusted = await trust(context, resource, signature.credential, "id");
|
|
58
66
|
if (trusted.user.credential == null) {
|
|
59
67
|
return false;
|
|
60
68
|
}
|
|
69
|
+
const timestamp = req.headers?.[timeKey];
|
|
70
|
+
const nonce = req.headers?.[nonceKey];
|
|
61
71
|
const payload = {
|
|
62
72
|
body: req.body ?? {},
|
|
63
|
-
headers: { [timeKey]:
|
|
73
|
+
headers: { [timeKey]: timestamp, [nonceKey]: nonce }
|
|
64
74
|
};
|
|
65
75
|
if (!await trusted.key.verify(payload, signature.signature)) {
|
|
66
76
|
return false;
|
|
67
77
|
}
|
|
68
|
-
if (typeof
|
|
78
|
+
if (typeof timestamp !== 'string') {
|
|
79
|
+
throw new AuthenPayloadError('timestamp');
|
|
80
|
+
}
|
|
81
|
+
const createdAt = new Date(timestamp);
|
|
82
|
+
const signedAt = createdAt.getTime();
|
|
83
|
+
if (!Number.isFinite(signedAt) || createdAt.toISOString() !== timestamp) {
|
|
69
84
|
throw new AuthenPayloadError('timestamp');
|
|
70
85
|
}
|
|
71
|
-
|
|
72
|
-
if (createdAt.getTime() + BED255_SIG_TTL < Date.now()) {
|
|
86
|
+
if (Math.abs(Date.now() - signedAt) > BED255_SIG_TTL) {
|
|
73
87
|
throw new AuthenPayloadError('expired');
|
|
74
88
|
}
|
|
75
|
-
|
|
76
|
-
if (typeof nonce !== 'string') {
|
|
89
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
77
90
|
throw new AuthenPayloadError('nonce');
|
|
78
91
|
}
|
|
79
92
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
// The replay identity is the signing credential plus its nonce. A nonce used by one
|
|
94
|
+
// trusted service does not block another, while a captured request can be admitted once.
|
|
95
|
+
const claimed = await replay(context).claim(`${signature.credential.length}:${signature.credential}${nonce}`, new Date(signedAt + BED255_SIG_TTL));
|
|
96
|
+
if (!claimed) {
|
|
97
|
+
throw new AuthenPayloadError('nonce');
|
|
83
98
|
}
|
|
84
99
|
}
|
|
85
100
|
catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EACL,mBAAmB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,aAAa,EAC7E,cAAc,EACf,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAG9C,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEhF,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EACL,mBAAmB,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,aAAa,EAC7E,cAAc,EACf,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAG9C,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAEhF,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAC9D,OAAO,EACL,kCAAkC,EAAE,oCAAoC,EACzE,MAAM,aAAa,CAAA;AAGpB,MAAM,OAAO,GAAG,kBAAkB,CAAC,iBAAiB,EAAE,CAAA;AACtD,MAAM,QAAQ,GAAG,mBAAmB,CAAC,iBAAiB,EAAE,CAAA;AAExD,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,QAAgB,EAAE,IAA+B,EAAqB,EAAE;IAC5G,MAAM,YAAY,GAAG,kCAAkC,EAAE,CAAA;IAEzD,MAAM,MAAM,GAAG,CAAC,OAAgB,EAA4B,EAAE;QAC5D,IAAI,IAAI,EAAE,MAAM,IAAI,IAAI,EAAE,CAAC;YACzB,OAAO,IAAI,CAAC,MAAM,CAAA;QACpB,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,IAAI,qBAAqB,CAAA;QAClD,IAAI,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;YAC/B,OAAO,oCAAoC,CACzC,OAAO,CAAC,QAAQ,CAA8B,KAAK,CAAC,CACrD,CAAA;QACH,CAAC;QACD,OAAO,YAAY,CAAA;IACrB,CAAC,CAAA;IAED,MAAM,KAAK,GAAsB,aAAa,CAAoB,aAAa,EAAE;QAC/E,aAAa,EAAE,KAAK,EAAC,GAAG,EAAC,EAAE;YACzB,MAAM,OAAO,GAAG,aAAa,CAAkB,KAAK,CAAC,GAAG,CAAC,CAAA;YACzD,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;YAEzF,qBAAqB;YACrB,IAAI,GAAG,IAAI,IAAI,IAAI,QAAQ,CAAC,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;gBAChD,IAAI,GAAG,CAAC,OAAO,IAAI,IAAI,EAAE,CAAC;oBACxB,GAAG,CAAC,OAAO,GAAG,EAAE,CAAA;gBAClB,CAAC;gBACD,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;gBAC/C,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,gBAAgB,CAAC,EAAE,CAAC,CAAA;gBAE5C,MAAM,OAAO,GAAG;oBACd,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE;oBACpB,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE;iBAChF,CAAA;gBAED,MAAM,KAAK,GAAG;oBACZ,iBAAiB,CAAC,qBAAqB,CAAC,WAAW,EAAE;oBACnD,CAAC,YAAY,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;oBAChC,CAAC,WAAW,EAAE,MAAM,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;iBAClD,CAAA;gBAED,OAAO,KAAK,CAAC,GAAG,CACd,IAAI,CAAC,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CACpD,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;YACb,CAAC;YAED,OAAO,IAAI,CAAA;QACb,CAAC;QAED,KAAK,EAAE,KAAK,EAAC,GAAG,EAAC,EAAE,CACjB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,CAAC,qBAAqB,CAAC,IAAI,IAAI;QAExE,MAAM,EAAE,KAAK,EAAK,GAAoB,EAAE,GAA2B,EAAE,EAAE;YACrE,MAAM,OAAO,GAAG,aAAa,CAAkB,KAAK,CAAC,GAAG,CAAC,CAAA;YACzD,MAAM,aAAa,GAAG,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,CAAC,qBAAqB,CAAC,CAAA;YACpF,IAAI,aAAa,IAAI,IAAI,EAAE,CAAC;gBAC1B,OAAO,KAAU,CAAA;YACnB,CAAC;YAED,MAAM,SAAS,GAAG,aAAa,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE;gBACpE,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;oBACxB,OAAO,MAAM,CAAA;gBACf,CAAC;gBACD,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;gBACvC,OAAO,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,EAAE,KAAK,EAAE,CAAA;YAClD,CAAC,EAAE,EAA+C,CAAC,CAAA;YAEnD,IAAI,OAAO,SAAS,CAAC,UAAU,KAAK,QAAQ,IAAI,SAAS,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;mBAC5E,OAAO,SAAS,CAAC,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACjF,MAAM,IAAI,kBAAkB,CAAC,WAAW,CAAC,CAAA;YAC3C,CAAC;YAED,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAC,UAAU,EAAE,IAAI,CAAC,CAAA;YAC1E,IAAI,OAAO,CAAC,IAAI,CAAC,UAAU,IAAI,IAAI,EAAE,CAAC;gBACpC,OAAO,KAAU,CAAA;YACnB,CAAC;YAED,MAAM,SAAS,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,CAAA;YACxC,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC,QAAQ,CAAC,CAAA;YAErC,MAAM,OAAO,GAAG;gBACd,IAAI,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE;gBACpB,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE;aACrD,CAAA;YAED,IAAI,CAAC,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC;gBAC5D,OAAO,KAAU,CAAA;YACnB,CAAC;YAED,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;gBAClC,MAAM,IAAI,kBAAkB,CAAC,WAAW,CAAC,CAAA;YAC3C,CAAC;YAED,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,CAAA;YACrC,MAAM,QAAQ,GAAG,SAAS,CAAC,OAAO,EAAE,CAAA;YACpC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,SAAS,CAAC,WAAW,EAAE,KAAK,SAAS,EAAE,CAAC;gBACxE,MAAM,IAAI,kBAAkB,CAAC,WAAW,CAAC,CAAA;YAC3C,CAAC;YAED,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,CAAC,GAAG,cAAc,EAAE,CAAC;gBACrD,MAAM,IAAI,kBAAkB,CAAC,SAAS,CAAC,CAAA;YACzC,CAAC;YAED,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACpD,MAAM,IAAI,kBAAkB,CAAC,OAAO,CAAC,CAAA;YACvC,CAAC;YAED,IAAI,CAAC;gBACH,oFAAoF;gBACpF,yFAAyF;gBACzF,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,CACzC,GAAG,SAAS,CAAC,UAAU,CAAC,MAAM,IAAI,SAAS,CAAC,UAAU,GAAG,KAAK,EAAE,EAChE,IAAI,IAAI,CAAC,QAAQ,GAAG,cAAc,CAAC,CACpC,CAAA;gBACD,IAAI,CAAC,OAAO,EAAE,CAAC;oBACb,MAAM,IAAI,kBAAkB,CAAC,OAAO,CAAC,CAAA;gBACvC,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,MAAM,IAAI,kBAAkB,CAAC,OAAO,CAAC,CAAA;YACvC,CAAC;YAED,GAAG,CAAC,OAAO,CAAC;gBACV,KAAK,EAAE,aAAa;gBACpB,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE;gBACvB,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE;gBAC1B,UAAU,EAAE,OAAO,CAAC,IAAI,CAAC,UAAU;gBACnC,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE;gBAC5D,IAAI,EAAE,iBAAiB,CAAC,qBAAqB;gBAC7C,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,OAAO;gBAC3B;;mBAEG;gBACH,IAAI,EAAE,QAAQ,CAAC,OAAO;gBACtB,MAAM,EAAE,KAAK;gBACb,SAAS,EAAE,IAAI,IAAI,EAAE;aACtB,CAAC,CAAA;YAEF,OAAO,IAAS,CAAA;QAClB,CAAC;KACF,EAAE,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE;QACrB,KAAK,CAAC,WAAW,GAAG,IAAI,CAAA;IAC1B,CAAC,CAAC,CAAA;IAEF,OAAO,KAAK,CAAA;AACd,CAAC,CAAA"}
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import type { GuardService } from '@owlmeans/entrypoint';
|
|
2
|
+
import type { ResourceRecord, WriteOptions } from '@owlmeans/resource';
|
|
3
|
+
import type { BasicResource } from '@owlmeans/context';
|
|
2
4
|
export interface BasicEd25519Guard extends GuardService {
|
|
3
5
|
}
|
|
4
6
|
export interface BasicEd25519GuardOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Legacy resource alias. A Redis resource registered under this alias gives replay protection
|
|
9
|
+
* shared by every replica; when it is absent the guard falls back to its process-local store.
|
|
10
|
+
*/
|
|
5
11
|
cache?: string;
|
|
12
|
+
/** Explicit replay store, for consumers that do not register a Resource on the context. */
|
|
13
|
+
replay?: SignedRequestReplayStore;
|
|
14
|
+
}
|
|
15
|
+
/** A create-once claim over one signed request identity. */
|
|
16
|
+
export interface SignedRequestReplayStore {
|
|
17
|
+
claim: (id: string, expiresAt: Date) => Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
/** The small Resource surface needed by the Redis-compatible replay adapter. */
|
|
20
|
+
export interface SignedRequestReplayResource extends BasicResource {
|
|
21
|
+
create: (record: Partial<ResourceRecord>, opts?: WriteOptions) => Promise<ResourceRecord>;
|
|
6
22
|
}
|
|
7
23
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/guards/basic-ed25519/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACxD,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACtE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEtD,MAAM,WAAW,iBAAkB,SAAQ,YAAY;CACtD;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2FAA2F;IAC3F,MAAM,CAAC,EAAE,wBAAwB,CAAA;CAClC;AAED,4DAA4D;AAC5D,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CACzD;AAED,gFAAgF;AAChF,MAAM,WAAW,2BAA4B,SAAQ,aAAa;IAChE,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,cAAc,CAAC,EAAE,IAAI,CAAC,EAAE,YAAY,KAAK,OAAO,CAAC,cAAc,CAAC,CAAA;CAC1F"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/auth-common",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.30",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -29,19 +29,19 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
33
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
34
|
-
"@owlmeans/basic-ids": "^0.1.18-rc.
|
|
35
|
-
"@owlmeans/basic-keys": "^0.1.18-rc.
|
|
36
|
-
"@owlmeans/client-entrypoint": "^0.1.18-rc.
|
|
37
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
38
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
39
|
-
"@owlmeans/resource": "^0.1.18-rc.
|
|
40
|
-
"@owlmeans/route": "^0.1.18-rc.
|
|
32
|
+
"@owlmeans/auth": "^0.1.18-rc.28",
|
|
33
|
+
"@owlmeans/auth-common": "^0.1.18-rc.30",
|
|
34
|
+
"@owlmeans/basic-ids": "^0.1.18-rc.27",
|
|
35
|
+
"@owlmeans/basic-keys": "^0.1.18-rc.31",
|
|
36
|
+
"@owlmeans/client-entrypoint": "^0.1.18-rc.30",
|
|
37
|
+
"@owlmeans/context": "^0.1.18-rc.26",
|
|
38
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.29",
|
|
39
|
+
"@owlmeans/resource": "^0.1.18-rc.28",
|
|
40
|
+
"@owlmeans/route": "^0.1.18-rc.27"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@owlmeans/dep-config": "workspace:*",
|
|
44
|
-
"@owlmeans/test-auth": "^0.1.18-rc.
|
|
44
|
+
"@owlmeans/test-auth": "^0.1.18-rc.29",
|
|
45
45
|
"@types/bun": "^1.4.0",
|
|
46
46
|
"nodemon": "^3.1.14",
|
|
47
47
|
"typescript": "^7.0.2"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { RecordExists } from '@owlmeans/resource'
|
|
2
|
+
import type { SignedRequestReplayResource, SignedRequestReplayStore } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process-local replay protection used when a deployment has not supplied a shared resource.
|
|
6
|
+
* A timer removes each claim at its signed expiry, keeping the fallback bounded by the live
|
|
7
|
+
* replay window rather than by process lifetime.
|
|
8
|
+
*/
|
|
9
|
+
export const makeMemorySignedRequestReplayStore = (): SignedRequestReplayStore => {
|
|
10
|
+
const claims = new Map<string, number>()
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
claim: async (id, expiresAt) => {
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
const current = claims.get(id)
|
|
16
|
+
if (current != null && current > now) {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const expiry = expiresAt.getTime()
|
|
21
|
+
claims.set(id, expiry)
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
if (claims.get(id) === expiry) {
|
|
24
|
+
claims.delete(id)
|
|
25
|
+
}
|
|
26
|
+
}, Math.max(0, expiry - now))
|
|
27
|
+
const unref = (timer as unknown as { unref?: () => void }).unref
|
|
28
|
+
unref?.call(timer)
|
|
29
|
+
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Adapt a create-once Resource to signed-request replay protection. Redis resources honour the
|
|
37
|
+
* absolute expiry and make the claim atomic across replicas (`SET NX`); other resource failures
|
|
38
|
+
* remain failures rather than being mistaken for successful claims.
|
|
39
|
+
*/
|
|
40
|
+
export const makeResourceSignedRequestReplayStore = (
|
|
41
|
+
resource: SignedRequestReplayResource
|
|
42
|
+
): SignedRequestReplayStore => ({
|
|
43
|
+
claim: async (id, expiresAt) => {
|
|
44
|
+
try {
|
|
45
|
+
await resource.create({ id }, { ttl: expiresAt })
|
|
46
|
+
return true
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof RecordExists
|
|
49
|
+
|| (error as { type?: unknown } | null)?.type === RecordExists.typeName) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
})
|
|
@@ -11,22 +11,30 @@ import type { BasicEd25519Guard, BasicEd25519GuardOptions } from './types.js'
|
|
|
11
11
|
import { AuthenPayloadError, AuthroizationType, AuthRole } from '@owlmeans/auth'
|
|
12
12
|
import type { Auth } from '@owlmeans/auth'
|
|
13
13
|
import { createIdOfLength } from '@owlmeans/basic-ids'
|
|
14
|
-
import type { Resource, ResourceRecord } from '@owlmeans/resource'
|
|
15
14
|
import { extractAuthToken } from '@owlmeans/auth-common/utils'
|
|
15
|
+
import {
|
|
16
|
+
makeMemorySignedRequestReplayStore, makeResourceSignedRequestReplayStore
|
|
17
|
+
} from './replay.js'
|
|
18
|
+
import type { SignedRequestReplayResource, SignedRequestReplayStore } from './types.js'
|
|
16
19
|
|
|
17
20
|
const timeKey = BED255_TIME_HEADER.toLocaleLowerCase()
|
|
18
21
|
const nonceKey = BED255_NONCE_HEADER.toLocaleLowerCase()
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
* Proper configuration:
|
|
22
|
-
* - One needs to specify opts.cache as a redis resource that stores nonces for a while to
|
|
23
|
-
* avoid requests duplication.
|
|
24
|
-
*/
|
|
25
23
|
export const makeBasicEd25519Guard = (resource: string, opts?: BasicEd25519GuardOptions): BasicEd25519Guard => {
|
|
24
|
+
const memoryReplay = makeMemorySignedRequestReplayStore()
|
|
26
25
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const replay = (context: Context): SignedRequestReplayStore => {
|
|
27
|
+
if (opts?.replay != null) {
|
|
28
|
+
return opts.replay
|
|
29
|
+
}
|
|
30
|
+
const alias = opts?.cache ?? BED255_CASHE_RESOURCE
|
|
31
|
+
if (context.hasResource(alias)) {
|
|
32
|
+
return makeResourceSignedRequestReplayStore(
|
|
33
|
+
context.resource<SignedRequestReplayResource>(alias)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return memoryReplay
|
|
37
|
+
}
|
|
30
38
|
|
|
31
39
|
const guard: BasicEd25519Guard = createService<BasicEd25519Guard>(GUARD_ED25519, {
|
|
32
40
|
authenticated: async req => {
|
|
@@ -78,39 +86,55 @@ export const makeBasicEd25519Guard = (resource: string, opts?: BasicEd25519Guard
|
|
|
78
86
|
return { ...result, [key.toLowerCase()]: value }
|
|
79
87
|
}, {} as { credential: string, signature: string })
|
|
80
88
|
|
|
89
|
+
if (typeof signature.credential !== 'string' || signature.credential.length === 0
|
|
90
|
+
|| typeof signature.signature !== 'string' || signature.signature.length === 0) {
|
|
91
|
+
throw new AuthenPayloadError('signature')
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
const trusted = await trust(context, resource, signature.credential, "id")
|
|
82
95
|
if (trusted.user.credential == null) {
|
|
83
96
|
return false as T
|
|
84
97
|
}
|
|
85
98
|
|
|
99
|
+
const timestamp = req.headers?.[timeKey]
|
|
100
|
+
const nonce = req.headers?.[nonceKey]
|
|
101
|
+
|
|
86
102
|
const payload = {
|
|
87
103
|
body: req.body ?? {},
|
|
88
|
-
headers: { [timeKey]:
|
|
104
|
+
headers: { [timeKey]: timestamp, [nonceKey]: nonce }
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
if (!await trusted.key.verify(payload, signature.signature)) {
|
|
92
108
|
return false as T
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
if (typeof
|
|
111
|
+
if (typeof timestamp !== 'string') {
|
|
96
112
|
throw new AuthenPayloadError('timestamp')
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
const createdAt = new Date(
|
|
115
|
+
const createdAt = new Date(timestamp)
|
|
116
|
+
const signedAt = createdAt.getTime()
|
|
117
|
+
if (!Number.isFinite(signedAt) || createdAt.toISOString() !== timestamp) {
|
|
118
|
+
throw new AuthenPayloadError('timestamp')
|
|
119
|
+
}
|
|
100
120
|
|
|
101
|
-
if (
|
|
121
|
+
if (Math.abs(Date.now() - signedAt) > BED255_SIG_TTL) {
|
|
102
122
|
throw new AuthenPayloadError('expired')
|
|
103
123
|
}
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
if (typeof nonce !== 'string') {
|
|
125
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
107
126
|
throw new AuthenPayloadError('nonce')
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
// The replay identity is the signing credential plus its nonce. A nonce used by one
|
|
131
|
+
// trusted service does not block another, while a captured request can be admitted once.
|
|
132
|
+
const claimed = await replay(context).claim(
|
|
133
|
+
`${signature.credential.length}:${signature.credential}${nonce}`,
|
|
134
|
+
new Date(signedAt + BED255_SIG_TTL)
|
|
135
|
+
)
|
|
136
|
+
if (!claimed) {
|
|
137
|
+
throw new AuthenPayloadError('nonce')
|
|
114
138
|
}
|
|
115
139
|
} catch {
|
|
116
140
|
throw new AuthenPayloadError('nonce')
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import type { GuardService } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { ResourceRecord, WriteOptions } from '@owlmeans/resource'
|
|
3
|
+
import type { BasicResource } from '@owlmeans/context'
|
|
2
4
|
|
|
3
5
|
export interface BasicEd25519Guard extends GuardService {
|
|
4
6
|
}
|
|
5
7
|
|
|
6
8
|
export interface BasicEd25519GuardOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Legacy resource alias. A Redis resource registered under this alias gives replay protection
|
|
11
|
+
* shared by every replica; when it is absent the guard falls back to its process-local store.
|
|
12
|
+
*/
|
|
7
13
|
cache?: string
|
|
14
|
+
/** Explicit replay store, for consumers that do not register a Resource on the context. */
|
|
15
|
+
replay?: SignedRequestReplayStore
|
|
8
16
|
}
|
|
9
17
|
|
|
18
|
+
/** A create-once claim over one signed request identity. */
|
|
19
|
+
export interface SignedRequestReplayStore {
|
|
20
|
+
claim: (id: string, expiresAt: Date) => Promise<boolean>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The small Resource surface needed by the Redis-compatible replay adapter. */
|
|
24
|
+
export interface SignedRequestReplayResource extends BasicResource {
|
|
25
|
+
create: (record: Partial<ResourceRecord>, opts?: WriteOptions) => Promise<ResourceRecord>
|
|
26
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AuthenPayloadError, AuthroizationType } from '@owlmeans/auth'
|
|
3
|
+
import {
|
|
4
|
+
BED255_NONCE_HEADER, BED255_SIG_TTL, BED255_TIME_HEADER, makeBasicEd25519Guard,
|
|
5
|
+
makeResourceSignedRequestReplayStore
|
|
6
|
+
} from '@owlmeans/auth-common'
|
|
7
|
+
import type { SignedRequestReplayResource } from '@owlmeans/auth-common'
|
|
8
|
+
import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
|
|
9
|
+
import type { Auth } from '@owlmeans/auth'
|
|
10
|
+
import { RecordExists } from '@owlmeans/resource'
|
|
11
|
+
import { makeTrustFixture, TRUSTED_ALIAS } from './context.js'
|
|
12
|
+
|
|
13
|
+
const timeKey = BED255_TIME_HEADER.toLowerCase()
|
|
14
|
+
const nonceKey = BED255_NONCE_HEADER.toLowerCase()
|
|
15
|
+
|
|
16
|
+
const request = async (timestamp: string, nonce: string) => {
|
|
17
|
+
const fixture = makeTrustFixture({ withSecret: true, name: 'auth-common-tests' })
|
|
18
|
+
const body = { action: 'publish' }
|
|
19
|
+
const signature = await fixture.authKey.sign({
|
|
20
|
+
body,
|
|
21
|
+
headers: { [timeKey]: timestamp, [nonceKey]: nonce }
|
|
22
|
+
})
|
|
23
|
+
const req = {
|
|
24
|
+
alias: 'signed', body, headers: {
|
|
25
|
+
[timeKey]: timestamp,
|
|
26
|
+
[nonceKey]: nonce,
|
|
27
|
+
authorization: `${AuthroizationType.Ed25519BasicSignature.toUpperCase()} `
|
|
28
|
+
+ `Credential=${fixture.authRecord.id} Signature=${signature}`,
|
|
29
|
+
}, params: {}, query: {}, path: '/signed'
|
|
30
|
+
} satisfies AbstractRequest
|
|
31
|
+
const guard = makeBasicEd25519Guard(TRUSTED_ALIAS)
|
|
32
|
+
fixture.ctx.registerService(guard)
|
|
33
|
+
|
|
34
|
+
return { fixture, guard, req }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handle = async (
|
|
38
|
+
guard: ReturnType<typeof makeBasicEd25519Guard>, req: AbstractRequest
|
|
39
|
+
): Promise<Auth | undefined> => {
|
|
40
|
+
let value: Auth | undefined
|
|
41
|
+
const response: AbstractResponse<Auth> = {
|
|
42
|
+
resolve: resolved => { value = resolved },
|
|
43
|
+
reject: error => { throw error }
|
|
44
|
+
}
|
|
45
|
+
await guard.handle(req, response)
|
|
46
|
+
return value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('@owlmeans/auth-common — signed request freshness and replay protection', () => {
|
|
50
|
+
test('accepts a canonical current timestamp once and rejects the replay by default', async () => {
|
|
51
|
+
const signed = await request(new Date().toISOString(), 'one-use-nonce')
|
|
52
|
+
|
|
53
|
+
expect((await handle(signed.guard, signed.req))?.userId).toBe(signed.fixture.authRecord.id)
|
|
54
|
+
await expect(handle(signed.guard, signed.req)).rejects.toBeInstanceOf(AuthenPayloadError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects parseable but non-canonical timestamps', async () => {
|
|
58
|
+
const signed = await request(new Date().toUTCString(), 'non-canonical')
|
|
59
|
+
|
|
60
|
+
await expect(handle(signed.guard, signed.req)).rejects.toBeInstanceOf(AuthenPayloadError)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('rejects timestamps outside the symmetric 60-second window', async () => {
|
|
64
|
+
const past = await request(new Date(Date.now() - BED255_SIG_TTL - 1_000).toISOString(), 'past')
|
|
65
|
+
const future = await request(new Date(Date.now() + BED255_SIG_TTL + 1_000).toISOString(), 'future')
|
|
66
|
+
|
|
67
|
+
await expect(handle(past.guard, past.req)).rejects.toBeInstanceOf(AuthenPayloadError)
|
|
68
|
+
await expect(handle(future.guard, future.req)).rejects.toBeInstanceOf(AuthenPayloadError)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('rejects authentication when an injected replay store fails', async () => {
|
|
72
|
+
const signed = await request(new Date().toISOString(), 'store-failure')
|
|
73
|
+
const guard = makeBasicEd25519Guard(TRUSTED_ALIAS, {
|
|
74
|
+
replay: { claim: async () => { throw new Error('store unavailable') } }
|
|
75
|
+
})
|
|
76
|
+
signed.fixture.ctx.registerService(guard)
|
|
77
|
+
|
|
78
|
+
await expect(handle(guard, signed.req)).rejects.toBeInstanceOf(AuthenPayloadError)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('adapts a create-once resource and forwards the absolute signed expiry', async () => {
|
|
82
|
+
let claimed = false
|
|
83
|
+
let observedExpiry: Date | undefined
|
|
84
|
+
const resource = {
|
|
85
|
+
create: async (record: { id?: string }, opts?: { ttl?: number | Date }) => {
|
|
86
|
+
if (claimed) throw new RecordExists(record.id ?? '')
|
|
87
|
+
claimed = true
|
|
88
|
+
observedExpiry = opts?.ttl as Date
|
|
89
|
+
return record
|
|
90
|
+
}
|
|
91
|
+
} as SignedRequestReplayResource
|
|
92
|
+
const replay = makeResourceSignedRequestReplayStore(resource)
|
|
93
|
+
const expiresAt = new Date(Date.now() + BED255_SIG_TTL)
|
|
94
|
+
|
|
95
|
+
expect(await replay.claim('service:nonce', expiresAt)).toBe(true)
|
|
96
|
+
expect(observedExpiry).toBe(expiresAt)
|
|
97
|
+
expect(await replay.claim('service:nonce', expiresAt)).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|