@owlmeans/auth-common 0.1.18-rc.9 → 0.1.18
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 +31 -17
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/auth-common/SKILL.md +124 -23
- package/build/consts.d.ts +9 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +9 -0
- package/build/consts.js.map +1 -1
- package/build/entity/types.d.ts +52 -0
- package/build/entity/types.d.ts.map +1 -0
- package/build/entity/types.js +2 -0
- package/build/entity/types.js.map +1 -0
- package/build/entity.d.ts +5 -37
- package/build/entity.d.ts.map +1 -1
- package/build/entity.js +44 -65
- package/build/entity.js.map +1 -1
- package/build/entrypoints.d.ts +41 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +46 -0
- package/build/entrypoints.js.map +1 -0
- package/build/guards/basic-ed25519/consts.local.d.ts +3 -0
- package/build/guards/basic-ed25519/consts.local.d.ts.map +1 -0
- package/build/guards/basic-ed25519/consts.local.js +4 -0
- package/build/guards/basic-ed25519/consts.local.js.map +1 -0
- 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 +34 -20
- 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/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -1
- package/build/index.js.map +1 -1
- package/build/middleware.d.ts +1 -1
- package/build/middleware.d.ts.map +1 -1
- package/build/middleware.js +13 -12
- package/build/middleware.js.map +1 -1
- package/build/modules.d.ts.map +1 -1
- package/build/modules.js +2 -11
- package/build/modules.js.map +1 -1
- package/build/schemas.d.ts +1 -13
- package/build/schemas.d.ts.map +1 -1
- package/build/schemas.js.map +1 -1
- package/build/types.d.ts +13 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +5 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/trusted.d.ts.map +1 -1
- package/build/utils/trusted.js +4 -3
- package/build/utils/trusted.js.map +1 -1
- package/package.json +13 -13
- package/src/consts.ts +10 -0
- package/src/entity/types.ts +52 -0
- package/src/entity.ts +51 -66
- package/src/entrypoints.ts +68 -0
- package/src/guards/basic-ed25519/consts.local.ts +4 -0
- 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 +44 -25
- package/src/guards/basic-ed25519/types.ts +17 -0
- package/src/index.ts +2 -1
- package/src/middleware.ts +15 -18
- package/src/schemas.ts +1 -14
- package/src/types.local.ts +5 -0
- package/src/types.ts +14 -0
- package/src/utils/trusted.ts +5 -4
- package/tests/basic-ed25519.spec.ts +99 -0
- package/tests/context.ts +1 -2
- package/tests/dispatcher.spec.ts +15 -15
- package/tests/entity.spec.ts +112 -0
- package/src/modules.ts +0 -65
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.local.d.ts","sourceRoot":"","sources":["../src/types.local.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAEnE,MAAM,WAAW,MAAO,SAAQ,gBAAgB,CAAC,OAAO,CAAC;IACvD,+BAA+B,CAAC,EAAE,OAAO,CAAA;CAC1C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.local.js","sourceRoot":"","sources":["../src/types.local.ts"],"names":[],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trusted.d.ts","sourceRoot":"","sources":["../../src/utils/trusted.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAIhD,eAAO,MAAM,KAAK,GAAU,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,YAAY,MAAM,YAAY,MAAM,UAAS,MAAM;;;
|
|
1
|
+
{"version":3,"file":"trusted.d.ts","sourceRoot":"","sources":["../../src/utils/trusted.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAIhD,eAAO,MAAM,KAAK,GAAU,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,YAAY,MAAM,YAAY,MAAM,UAAS,MAAM;;;EAWhI,CAAA"}
|
package/build/utils/trusted.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { keyHelper, makeKeyPairModel } from '@owlmeans/basic-keys';
|
|
2
2
|
export const trust = async (context, resource, userName, field = 'name') => {
|
|
3
|
-
const
|
|
3
|
+
const where = { [field]: userName };
|
|
4
|
+
const trustedUser = await context.resource(resource).load(where);
|
|
4
5
|
if (trustedUser == null) {
|
|
5
6
|
throw new SyntaxError(`Auth service trusted entity is not provided: ${userName}`);
|
|
6
7
|
}
|
|
7
8
|
// @TODO credential can really be undefined - we need to process it properly
|
|
8
|
-
const keyPair = trustedUser.secret != null ? makeKeyPairModel(trustedUser.secret) : fromPubKey(trustedUser.credential);
|
|
9
|
+
const keyPair = trustedUser.secret != null ? makeKeyPairModel(trustedUser.secret) : keyHelper.fromPubKey(trustedUser.credential);
|
|
9
10
|
return { user: trustedUser, key: keyPair };
|
|
10
11
|
};
|
|
11
12
|
//# sourceMappingURL=trusted.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trusted.js","sourceRoot":"","sources":["../../src/utils/trusted.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"trusted.js","sourceRoot":"","sources":["../../src/utils/trusted.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAElE,MAAM,CAAC,MAAM,KAAK,GAAG,KAAK,EAA0C,OAAU,EAAE,QAAgB,EAAE,QAAgB,EAAE,KAAK,GAAW,MAAM,EAAE,EAAE;IAC5I,MAAM,KAAK,GAA4B,EAAE,CAAC,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAA;IAC5D,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,QAAQ,CAA0B,QAAQ,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACzF,IAAI,WAAW,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,IAAI,WAAW,CAAC,gDAAgD,QAAQ,EAAE,CAAC,CAAA;IACnF,CAAC;IAED,4EAA4E;IAC5E,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC,CAAC,gBAAgB,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,WAAW,CAAC,UAAW,CAAC,CAAA;IAEjI,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,CAAA;AAC5C,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/auth-common",
|
|
3
|
-
"version": "0.1.18
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -29,20 +29,20 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@owlmeans/auth": "^0.1.18
|
|
33
|
-
"@owlmeans/auth-common": "^0.1.18
|
|
34
|
-
"@owlmeans/basic-ids": "^0.1.18
|
|
35
|
-
"@owlmeans/basic-keys": "^0.1.18
|
|
36
|
-
"@owlmeans/client-entrypoint": "^0.1.18
|
|
37
|
-
"@owlmeans/context": "^0.1.18
|
|
38
|
-
"@owlmeans/entrypoint": "^0.1.18
|
|
39
|
-
"@owlmeans/resource": "^0.1.18
|
|
40
|
-
"@owlmeans/route": "^0.1.18
|
|
32
|
+
"@owlmeans/auth": "^0.1.18",
|
|
33
|
+
"@owlmeans/auth-common": "^0.1.18",
|
|
34
|
+
"@owlmeans/basic-ids": "^0.1.18",
|
|
35
|
+
"@owlmeans/basic-keys": "^0.1.18",
|
|
36
|
+
"@owlmeans/client-entrypoint": "^0.1.18",
|
|
37
|
+
"@owlmeans/context": "^0.1.18",
|
|
38
|
+
"@owlmeans/entrypoint": "^0.1.18",
|
|
39
|
+
"@owlmeans/resource": "^0.1.18",
|
|
40
|
+
"@owlmeans/route": "^0.1.18"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@owlmeans/dep-config": "
|
|
44
|
-
"@owlmeans/test-auth": "^0.1.18
|
|
45
|
-
"@types/bun": "^1.4.
|
|
43
|
+
"@owlmeans/dep-config": "^0.1.18",
|
|
44
|
+
"@owlmeans/test-auth": "^0.1.18",
|
|
45
|
+
"@types/bun": "^1.4.2",
|
|
46
46
|
"nodemon": "^3.1.14",
|
|
47
47
|
"typescript": "^7.0.2"
|
|
48
48
|
},
|
package/src/consts.ts
CHANGED
|
@@ -58,3 +58,13 @@ export const authApi = {
|
|
|
58
58
|
},
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The request header an authenticated call carries, re-exported for the packages that only depend
|
|
64
|
+
* on this half of the vocabulary.
|
|
65
|
+
*
|
|
66
|
+
* One spelling, one home: it is declared by `@owlmeans/auth` and named here so a client-side
|
|
67
|
+
* package does not have to take a dependency on the whole auth package to recognise its own
|
|
68
|
+
* bearer on a request it is about to send.
|
|
69
|
+
*/
|
|
70
|
+
export { AUTH_HEADER } from '@owlmeans/auth'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { ResolvedEntity } from '@owlmeans/entrypoint'
|
|
3
|
+
|
|
4
|
+
/** The organization entity one request acts for — built once at the top of a handler, never stored. */
|
|
5
|
+
export interface EntityScope {
|
|
6
|
+
/**
|
|
7
|
+
* The value a handler should store and query organization-scoped records by.
|
|
8
|
+
*
|
|
9
|
+
* Prefers the entity's stable id, which is the whole point of resolving one: records keyed by it
|
|
10
|
+
* survive a rename untouched. Falls back to the slug on the token for deployments that register no
|
|
11
|
+
* resolver — there the slug IS the only identifier the system has, and refusing to serve them
|
|
12
|
+
* would break every installation backed by an external IAM.
|
|
13
|
+
*
|
|
14
|
+
* Never use this to compose a user-facing name (a hostname, a display label). Those want the
|
|
15
|
+
* current slug, `req.entity?.slug`, precisely because it can change.
|
|
16
|
+
*/
|
|
17
|
+
entityKeyOf: () => string | undefined
|
|
18
|
+
/**
|
|
19
|
+
* `entityKeyOf` for handlers that cannot proceed without an organization.
|
|
20
|
+
*
|
|
21
|
+
* @throws {AuthorizationError} when the request carries no organization at all.
|
|
22
|
+
*/
|
|
23
|
+
requireEntityKey: () => string
|
|
24
|
+
/**
|
|
25
|
+
* The full resolved entity, for handlers that need the slug or the frozen key as well as the id.
|
|
26
|
+
*
|
|
27
|
+
* @throws {AuthorizationError} when no resolver is registered or the entity did not resolve.
|
|
28
|
+
*/
|
|
29
|
+
requireEntity: () => ResolvedEntity
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the organization a just-authenticated request acts for, and attach it.
|
|
32
|
+
*
|
|
33
|
+
* Called wherever authentication is ESTABLISHED — the HTTP boundary, and any socket that
|
|
34
|
+
* authenticates on its own once the connection is already open. Both need it for the same reason:
|
|
35
|
+
* the token names the organization by a slug that can move, while everything downstream keys on
|
|
36
|
+
* the record. A path that authenticates without calling this leaves `request.entity` empty, and
|
|
37
|
+
* its handlers quietly fall back to comparing a slug against stored ids — which surfaces as "this
|
|
38
|
+
* project does not exist" rather than as a missing resolution, and costs an afternoon to find.
|
|
39
|
+
*
|
|
40
|
+
* An entity the guard already attached is kept while its slug is exactly the token's: a guard
|
|
41
|
+
* whose authority names the organization itself (an OIDC session of a tenanted client) is the only
|
|
42
|
+
* source such a deployment has, because the registry that owns the organization is not its own.
|
|
43
|
+
* One whose slug differs is never trusted — it is dropped, and resolution proceeds as if it had
|
|
44
|
+
* not been there.
|
|
45
|
+
*
|
|
46
|
+
* Otherwise a no-op when no resolver is registered: such a deployment has no organization store,
|
|
47
|
+
* and the slug is the only identifier it has.
|
|
48
|
+
*
|
|
49
|
+
* @throws {AuthenFailed} when the token names an organization that cannot be resolved.
|
|
50
|
+
*/
|
|
51
|
+
attachEntity: (context: BasicContext<any>) => Promise<ResolvedEntity | undefined>
|
|
52
|
+
}
|
package/src/entity.ts
CHANGED
|
@@ -1,82 +1,67 @@
|
|
|
1
1
|
import type { AbstractRequest, ResolvedEntity } from '@owlmeans/entrypoint'
|
|
2
2
|
import type { BasicContext } from '@owlmeans/context'
|
|
3
|
-
import { AuthenFailed, AuthorizationError,
|
|
3
|
+
import { AuthenFailed, AuthorizationError, authHelper } from '@owlmeans/auth'
|
|
4
4
|
import type { EntityResolverService } from './types.js'
|
|
5
5
|
import { ENTITY_RESOLVER } from './consts.js'
|
|
6
|
+
import type { EntityScope } from './entity/types.js'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* Prefers the entity's stable id, which is the whole point of resolving one: records keyed by it
|
|
11
|
-
* survive a rename untouched. Falls back to the slug on the token for deployments that register no
|
|
12
|
-
* resolver — there the slug IS the only identifier the system has, and refusing to serve them
|
|
13
|
-
* would break every installation backed by an external IAM.
|
|
14
|
-
*
|
|
15
|
-
* Never use this to compose a user-facing name (a hostname, a display label). Those want the
|
|
16
|
-
* current slug, `req.entity?.slug`, precisely because it can change.
|
|
17
|
-
*/
|
|
18
|
-
export const entityKeyOf = (req: AbstractRequest): string | undefined =>
|
|
19
|
-
req.entity?.id ?? entitySlugOf(req.auth)
|
|
8
|
+
export const makeEntityScope = (req: AbstractRequest): EntityScope => {
|
|
9
|
+
const entityKeyOf = (): string | undefined =>
|
|
10
|
+
req.entity?.id ?? authHelper.entitySlugOf(req.auth)
|
|
20
11
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (key == null || key === '') {
|
|
29
|
-
throw new AuthorizationError()
|
|
12
|
+
const requireEntityKey = (): string => {
|
|
13
|
+
const key = entityKeyOf()
|
|
14
|
+
if (key == null || key === '') {
|
|
15
|
+
throw new AuthorizationError()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return key
|
|
30
19
|
}
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
const requireEntity = (): ResolvedEntity => {
|
|
22
|
+
if (req.entity == null) {
|
|
23
|
+
throw new AuthorizationError()
|
|
24
|
+
}
|
|
34
25
|
|
|
35
|
-
|
|
36
|
-
* The full resolved entity, for handlers that need the slug or the frozen key as well as the id.
|
|
37
|
-
*
|
|
38
|
-
* @throws {AuthorizationError} when no resolver is registered or the entity did not resolve.
|
|
39
|
-
*/
|
|
40
|
-
export const requireEntity = (req: AbstractRequest): ResolvedEntity => {
|
|
41
|
-
if (req.entity == null) {
|
|
42
|
-
throw new AuthorizationError()
|
|
26
|
+
return req.entity
|
|
43
27
|
}
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
|
|
29
|
+
const attachEntity = async (context: BasicContext<any>): Promise<ResolvedEntity | undefined> => {
|
|
30
|
+
const slug = authHelper.entitySlugOf(req.auth)
|
|
31
|
+
if (req.entity != null) {
|
|
32
|
+
if (slug != null && req.entity.slug === slug) {
|
|
33
|
+
return req.entity
|
|
34
|
+
}
|
|
35
|
+
req.entity = undefined
|
|
36
|
+
}
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
* Called wherever authentication is ESTABLISHED — the HTTP boundary, and any socket that
|
|
52
|
-
* authenticates on its own once the connection is already open. Both need it for the same reason:
|
|
53
|
-
* the token names the organization by a slug that can move, while everything downstream keys on
|
|
54
|
-
* the record. A path that authenticates without calling this leaves `request.entity` empty, and
|
|
55
|
-
* its handlers quietly fall back to comparing a slug against stored ids — which surfaces as "this
|
|
56
|
-
* project does not exist" rather than as a missing resolution, and costs an afternoon to find.
|
|
57
|
-
*
|
|
58
|
-
* A no-op when no resolver is registered: such a deployment has no organization store, and the
|
|
59
|
-
* slug is the only identifier it has.
|
|
60
|
-
*
|
|
61
|
-
* @throws {AuthenFailed} when the token names an organization that cannot be resolved.
|
|
62
|
-
*/
|
|
63
|
-
export const attachEntity = async (
|
|
64
|
-
context: BasicContext<any>, request: AbstractRequest
|
|
65
|
-
): Promise<ResolvedEntity | undefined> => {
|
|
66
|
-
const slug = entitySlugOf(request.auth)
|
|
67
|
-
if (slug == null || !context.hasService(ENTITY_RESOLVER)) {
|
|
68
|
-
return undefined
|
|
69
|
-
}
|
|
38
|
+
if (slug == null || !context.hasService(ENTITY_RESOLVER)) {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
70
41
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
42
|
+
const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(slug)
|
|
43
|
+
if (entity == null) {
|
|
44
|
+
throw new AuthenFailed('entity')
|
|
45
|
+
}
|
|
75
46
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
47
|
+
// Canonicalize: a request that arrived under a retired slug continues under the current one, so
|
|
48
|
+
// nothing echoing the value back can re-publish a name the organization has dropped.
|
|
49
|
+
req.auth!.entitySlug = entity.slug
|
|
50
|
+
req.entity = entity
|
|
80
51
|
|
|
81
|
-
|
|
52
|
+
return entity
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { entityKeyOf, requireEntityKey, requireEntity, attachEntity }
|
|
82
56
|
}
|
|
57
|
+
|
|
58
|
+
/** @deprecated compat:factory-refactor — use `makeEntityScope(req).entityKeyOf()` */
|
|
59
|
+
export const entityKeyOf = (req: AbstractRequest): string | undefined => makeEntityScope(req).entityKeyOf()
|
|
60
|
+
|
|
61
|
+
/** @deprecated compat:factory-refactor — use `makeEntityScope(req).requireEntityKey()` */
|
|
62
|
+
export const requireEntityKey = (req: AbstractRequest): string => makeEntityScope(req).requireEntityKey()
|
|
63
|
+
|
|
64
|
+
/** @deprecated compat:factory-refactor — use `makeEntityScope(request).attachEntity(context)` */
|
|
65
|
+
export const attachEntity = async (
|
|
66
|
+
context: BasicContext<any>, request: AbstractRequest
|
|
67
|
+
): Promise<ResolvedEntity | undefined> => await makeEntityScope(request).attachEntity(context)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
|
|
2
|
+
import { AUTHEN, AUTHEN_AUTHEN, AUTHEN_INIT, AUTHEN_RELY, AllowanceRequestSchema, AuthCredentialsSchema, AuthTokenSchema, CAUTHEN, CAUTHEN_AUTHEN, CAUTHEN_AUTHEN_DEFAULT, CAUTHEN_AUTHEN_TYPED, DISPATCHER, DISPATCHER_AUTHEN, DISPATCHER_SURROGATE, OptionalAuthTokenSchema, CAUTHEN_FLOW_ENTER, type AllowanceRequest, type AllowanceResponse, type AuthCredentials, type AuthToken } from '@owlmeans/auth'
|
|
3
|
+
// import { AppType } from '@owlmeans/context'
|
|
4
|
+
import { contract, openProtocol, protocol, typed } from '@owlmeans/entrypoint'
|
|
5
|
+
import { route, RouteMethod, frontend, backend, socket } from '@owlmeans/route'
|
|
6
|
+
import { DISPATCHER_PATH, SURROGATE_PATH, WEB_API, authApi } from './consts.js'
|
|
7
|
+
import { SurrogateQuerySchema } from './schemas.js'
|
|
8
|
+
import type { SurrogateQuery } from './types.js'
|
|
9
|
+
|
|
10
|
+
/** Shared authentication protocols, bound independently by server and browser packages. */
|
|
11
|
+
export const authProtocols = {
|
|
12
|
+
authen: openProtocol(route(AUTHEN, '/authentication', backend())),
|
|
13
|
+
init: protocol(route(AUTHEN_INIT, '/init', backend(AUTHEN, RouteMethod.POST)),
|
|
14
|
+
contract.request({ body: typed<AllowanceRequest>(AllowanceRequestSchema) }, typed<AllowanceResponse>())),
|
|
15
|
+
authenticate: protocol(route(AUTHEN_AUTHEN, '/authenticate', backend(AUTHEN, RouteMethod.POST)),
|
|
16
|
+
contract.request({ body: typed<AuthCredentials>(AuthCredentialsSchema) }, typed<AuthToken>())),
|
|
17
|
+
rely: protocol(route(AUTHEN_RELY, '/rely', socket(AUTHEN)),
|
|
18
|
+
contract.request({ query: typed<Partial<AuthToken>>(OptionalAuthTokenSchema) }, typed<undefined>())),
|
|
19
|
+
client: openProtocol(route(CAUTHEN, '/authentication', frontend())),
|
|
20
|
+
login: openProtocol(route(CAUTHEN_AUTHEN, '/login', frontend(CAUTHEN))),
|
|
21
|
+
loginDefault: openProtocol(route(CAUTHEN_AUTHEN_DEFAULT, '/', frontend(CAUTHEN_AUTHEN, true))),
|
|
22
|
+
loginTyped: openProtocol(route(CAUTHEN_AUTHEN_TYPED, '/:type', frontend(CAUTHEN_AUTHEN))),
|
|
23
|
+
flowEnter: openProtocol(route(CAUTHEN_FLOW_ENTER, '/', frontend())),
|
|
24
|
+
dispatcher: protocol(
|
|
25
|
+
route(DISPATCHER, DISPATCHER_PATH, frontend({ service: DISPATCHER })),
|
|
26
|
+
contract.request({ query: typed<AuthToken>(AuthTokenSchema) }, typed()),
|
|
27
|
+
{ sticky: true },
|
|
28
|
+
),
|
|
29
|
+
// The login window an embedded application opens one level up.
|
|
30
|
+
//
|
|
31
|
+
// Top level and with no parent, so it renders outside every application layout — a popup must
|
|
32
|
+
// never show the application, with its navigation, inside itself. Sticky for the same reason the
|
|
33
|
+
// dispatcher is: it must attach to the client router regardless of service selection. It carries
|
|
34
|
+
// no `service`, unlike the dispatcher, because nothing server-side ever addresses it — the
|
|
35
|
+
// provider's callback goes to the dispatcher. And no guard: it is where a signed-out user lands.
|
|
36
|
+
surrogate: protocol(
|
|
37
|
+
route(DISPATCHER_SURROGATE, SURROGATE_PATH, frontend()),
|
|
38
|
+
contract.request({ query: typed<SurrogateQuery>(SurrogateQuerySchema) }, typed()),
|
|
39
|
+
{ sticky: true },
|
|
40
|
+
),
|
|
41
|
+
// This is a helper route that representes a API endpoint of service provider that wants to authenticate
|
|
42
|
+
// a user with OwlMeans server-auth library.
|
|
43
|
+
dispatcherAuthenticate: protocol(route(DISPATCHER_AUTHEN, '/authenticate', backend(null, RouteMethod.POST)),
|
|
44
|
+
contract.request({ body: typed<AuthToken>(AuthTokenSchema) }, typed<AuthToken>())),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared manager authentication protocols. */
|
|
48
|
+
export const managerProtocols = {
|
|
49
|
+
profile: {
|
|
50
|
+
base: openProtocol(route(authApi.profile.base, '/profile', backend({ service: WEB_API }))),
|
|
51
|
+
toEntityId: openProtocol(route(
|
|
52
|
+
authApi.profile.toEntityId, '/to-entity-id',
|
|
53
|
+
backend(authApi.profile.base, RouteMethod.POST)
|
|
54
|
+
)),
|
|
55
|
+
},
|
|
56
|
+
auth: {
|
|
57
|
+
base: openProtocol(route(authApi.auth.base, '/auth', backend({ service: WEB_API }))),
|
|
58
|
+
delegate: openProtocol(route(
|
|
59
|
+
authApi.auth.delegate, '/delegate',
|
|
60
|
+
backend(authApi.auth.base, RouteMethod.POST)
|
|
61
|
+
)),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// const skipEntrypoints = [DISPATCHER]
|
|
66
|
+
// export const authBackendEntrypoints = entrypoints.filter(
|
|
67
|
+
// module => !skipEntrypoints.includes(module.alias) && module.route.route.type === AppType.Backend
|
|
68
|
+
// ).map(module => module.alias)
|
|
@@ -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
|
+
})
|
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
import { createService } from '@owlmeans/context'
|
|
2
2
|
import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
|
|
3
3
|
import { assertContext } from '@owlmeans/context'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
BED255_SIG_TTL
|
|
7
|
-
} from './consts.js'
|
|
4
|
+
import { BED255_CASHE_RESOURCE, GUARD_ED25519, BED255_SIG_TTL } from './consts.js'
|
|
5
|
+
import { nonceKey, timeKey } from './consts.local.js'
|
|
8
6
|
import { trust } from '../../utils/trusted.js'
|
|
9
7
|
import type { Config, Context } from '../../utils/types.js'
|
|
10
8
|
import type { BasicEd25519Guard, BasicEd25519GuardOptions } from './types.js'
|
|
11
9
|
import { AuthenPayloadError, AuthroizationType, AuthRole } from '@owlmeans/auth'
|
|
12
10
|
import type { Auth } from '@owlmeans/auth'
|
|
13
11
|
import { createIdOfLength } from '@owlmeans/basic-ids'
|
|
14
|
-
import type { Resource, ResourceRecord } from '@owlmeans/resource'
|
|
15
12
|
import { extractAuthToken } from '@owlmeans/auth-common/utils'
|
|
13
|
+
import {
|
|
14
|
+
makeMemorySignedRequestReplayStore, makeResourceSignedRequestReplayStore
|
|
15
|
+
} from './replay.js'
|
|
16
|
+
import type { SignedRequestReplayResource, SignedRequestReplayStore } from './types.js'
|
|
16
17
|
|
|
17
|
-
const timeKey = BED255_TIME_HEADER.toLocaleLowerCase()
|
|
18
|
-
const nonceKey = BED255_NONCE_HEADER.toLocaleLowerCase()
|
|
19
|
-
|
|
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
18
|
export const makeBasicEd25519Guard = (resource: string, opts?: BasicEd25519GuardOptions): BasicEd25519Guard => {
|
|
19
|
+
const memoryReplay = makeMemorySignedRequestReplayStore()
|
|
26
20
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
const replay = (context: Context): SignedRequestReplayStore => {
|
|
22
|
+
if (opts?.replay != null) {
|
|
23
|
+
return opts.replay
|
|
24
|
+
}
|
|
25
|
+
const alias = opts?.cache ?? BED255_CASHE_RESOURCE
|
|
26
|
+
if (context.hasResource(alias)) {
|
|
27
|
+
return makeResourceSignedRequestReplayStore(
|
|
28
|
+
context.resource<SignedRequestReplayResource>(alias)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return memoryReplay
|
|
32
|
+
}
|
|
30
33
|
|
|
31
34
|
const guard: BasicEd25519Guard = createService<BasicEd25519Guard>(GUARD_ED25519, {
|
|
32
35
|
authenticated: async req => {
|
|
@@ -78,39 +81,55 @@ export const makeBasicEd25519Guard = (resource: string, opts?: BasicEd25519Guard
|
|
|
78
81
|
return { ...result, [key.toLowerCase()]: value }
|
|
79
82
|
}, {} as { credential: string, signature: string })
|
|
80
83
|
|
|
84
|
+
if (typeof signature.credential !== 'string' || signature.credential.length === 0
|
|
85
|
+
|| typeof signature.signature !== 'string' || signature.signature.length === 0) {
|
|
86
|
+
throw new AuthenPayloadError('signature')
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
const trusted = await trust(context, resource, signature.credential, "id")
|
|
82
90
|
if (trusted.user.credential == null) {
|
|
83
91
|
return false as T
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
const timestamp = req.headers?.[timeKey]
|
|
95
|
+
const nonce = req.headers?.[nonceKey]
|
|
96
|
+
|
|
86
97
|
const payload = {
|
|
87
98
|
body: req.body ?? {},
|
|
88
|
-
headers: { [timeKey]:
|
|
99
|
+
headers: { [timeKey]: timestamp, [nonceKey]: nonce }
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
if (!await trusted.key.verify(payload, signature.signature)) {
|
|
92
103
|
return false as T
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
if (typeof
|
|
106
|
+
if (typeof timestamp !== 'string') {
|
|
96
107
|
throw new AuthenPayloadError('timestamp')
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
const createdAt = new Date(
|
|
110
|
+
const createdAt = new Date(timestamp)
|
|
111
|
+
const signedAt = createdAt.getTime()
|
|
112
|
+
if (!Number.isFinite(signedAt) || createdAt.toISOString() !== timestamp) {
|
|
113
|
+
throw new AuthenPayloadError('timestamp')
|
|
114
|
+
}
|
|
100
115
|
|
|
101
|
-
if (
|
|
116
|
+
if (Math.abs(Date.now() - signedAt) > BED255_SIG_TTL) {
|
|
102
117
|
throw new AuthenPayloadError('expired')
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
|
|
106
|
-
if (typeof nonce !== 'string') {
|
|
120
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
107
121
|
throw new AuthenPayloadError('nonce')
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
// The replay identity is the signing credential plus its nonce. A nonce used by one
|
|
126
|
+
// trusted service does not block another, while a captured request can be admitted once.
|
|
127
|
+
const claimed = await replay(context).claim(
|
|
128
|
+
`${signature.credential.length}:${signature.credential}${nonce}`,
|
|
129
|
+
new Date(signedAt + BED255_SIG_TTL)
|
|
130
|
+
)
|
|
131
|
+
if (!claimed) {
|
|
132
|
+
throw new AuthenPayloadError('nonce')
|
|
114
133
|
}
|
|
115
134
|
} catch {
|
|
116
135
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export type * from './types.js'
|
|
2
|
+
export type * from './entity/types.js'
|
|
2
3
|
|
|
3
4
|
export * from './schemas.js'
|
|
4
|
-
export * from './
|
|
5
|
+
export * from './entrypoints.js'
|
|
5
6
|
export * from './consts.js'
|
|
6
7
|
export * from './entity.js'
|
|
7
8
|
export * from './middleware.js'
|
package/src/middleware.ts
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { MiddlewareType, MiddlewareStage, AppType } from '@owlmeans/context'
|
|
3
|
-
import { provideRequest } from '@owlmeans/client-entrypoint'
|
|
4
|
-
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
1
|
+
import { type Middleware, MiddlewareType, MiddlewareStage, AppType } from '@owlmeans/context'
|
|
5
2
|
import { AUTH_HEADER } from '@owlmeans/auth'
|
|
6
3
|
import type { AbstractRequest, GuardService } from '@owlmeans/entrypoint'
|
|
4
|
+
import type { Perked } from './types.local.js'
|
|
5
|
+
import { clientRequestHelper } from '@owlmeans/client-entrypoint'
|
|
7
6
|
|
|
8
7
|
export const authMiddleware: Middleware = {
|
|
9
8
|
type: MiddlewareType.Context,
|
|
10
9
|
stage: MiddlewareStage.Loading,
|
|
11
10
|
apply: async context => {
|
|
12
11
|
context.entrypoints<Perked>().forEach(module => {
|
|
13
|
-
if (module.route.route.type === AppType.Backend && module.
|
|
14
|
-
|
|
15
|
-
if (guards.length > 0) {
|
|
12
|
+
if (module.route.route.type === AppType.Backend && module.invoke != null) {
|
|
13
|
+
if (module.getGuards().length > 0) {
|
|
16
14
|
if (module._auth_common_middleware_applied === true) {
|
|
17
15
|
return
|
|
18
16
|
}
|
|
19
17
|
module._auth_common_middleware_applied = true
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// Wrapping `invoke` covers `call` too: `call` reads `invoke` off the entrypoint at the
|
|
19
|
+
// moment it runs, so both verbs carry the authentication header.
|
|
20
|
+
const invoke = module.invoke
|
|
21
|
+
module.invoke = (async req => {
|
|
23
22
|
// @TODO Actually we may use multiple authentication headers with the same name
|
|
24
23
|
// As I learnt its not always the case
|
|
25
|
-
|
|
24
|
+
// Asked per call, not captured: an ancestor may have gained a guard since the
|
|
25
|
+
// middleware wrapped this entrypoint.
|
|
26
|
+
const [token] = (await Promise.all(module.getGuards().map(
|
|
26
27
|
guard => context.service<GuardService>(guard).authenticated(req)
|
|
27
28
|
))).filter(token => token != null).reverse()
|
|
28
29
|
if (token != null) {
|
|
29
|
-
|
|
30
|
-
const _req: Partial<AbstractRequest> = req ?? provideRequest(module.getAlias(), module.getPath())
|
|
30
|
+
const _req: Partial<AbstractRequest> = req ?? clientRequestHelper.provideRequest(module.alias, module.path())
|
|
31
31
|
const headers = (_req.headers ?? {}) as Record<string, string | undefined>
|
|
32
32
|
if (headers[AUTH_HEADER] == null) {
|
|
33
33
|
headers[AUTH_HEADER] = token
|
|
@@ -36,8 +36,8 @@ export const authMiddleware: Middleware = {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
return
|
|
40
|
-
}
|
|
39
|
+
return invoke(req)
|
|
40
|
+
}) as typeof module.invoke
|
|
41
41
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -45,6 +45,3 @@ export const authMiddleware: Middleware = {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
interface Perked extends ClientEntrypoint<unknown> {
|
|
49
|
-
_auth_common_middleware_applied?: boolean
|
|
50
|
-
}
|