@owlmeans/auth-common 0.1.18-rc.8 → 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.
Files changed (84) hide show
  1. package/README.md +31 -17
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/auth-common/SKILL.md +124 -23
  4. package/build/consts.d.ts +28 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +28 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/entity/types.d.ts +52 -0
  9. package/build/entity/types.d.ts.map +1 -0
  10. package/build/entity/types.js +2 -0
  11. package/build/entity/types.js.map +1 -0
  12. package/build/entity.d.ts +11 -0
  13. package/build/entity.d.ts.map +1 -0
  14. package/build/entity.js +47 -0
  15. package/build/entity.js.map +1 -0
  16. package/build/entrypoints.d.ts +41 -0
  17. package/build/entrypoints.d.ts.map +1 -0
  18. package/build/entrypoints.js +46 -0
  19. package/build/entrypoints.js.map +1 -0
  20. package/build/guards/basic-ed25519/consts.local.d.ts +3 -0
  21. package/build/guards/basic-ed25519/consts.local.d.ts.map +1 -0
  22. package/build/guards/basic-ed25519/consts.local.js +4 -0
  23. package/build/guards/basic-ed25519/consts.local.js.map +1 -0
  24. package/build/guards/basic-ed25519/index.d.ts +1 -0
  25. package/build/guards/basic-ed25519/index.d.ts.map +1 -1
  26. package/build/guards/basic-ed25519/index.js +1 -0
  27. package/build/guards/basic-ed25519/index.js.map +1 -1
  28. package/build/guards/basic-ed25519/replay.d.ts +14 -0
  29. package/build/guards/basic-ed25519/replay.d.ts.map +1 -0
  30. package/build/guards/basic-ed25519/replay.js +49 -0
  31. package/build/guards/basic-ed25519/replay.js.map +1 -0
  32. package/build/guards/basic-ed25519/service.d.ts +0 -5
  33. package/build/guards/basic-ed25519/service.d.ts.map +1 -1
  34. package/build/guards/basic-ed25519/service.js +35 -21
  35. package/build/guards/basic-ed25519/service.js.map +1 -1
  36. package/build/guards/basic-ed25519/types.d.ts +16 -0
  37. package/build/guards/basic-ed25519/types.d.ts.map +1 -1
  38. package/build/helper.d.ts +1 -0
  39. package/build/helper.d.ts.map +1 -0
  40. package/build/helper.js +2 -0
  41. package/build/helper.js.map +1 -0
  42. package/build/index.d.ts +4 -1
  43. package/build/index.d.ts.map +1 -1
  44. package/build/index.js +3 -1
  45. package/build/index.js.map +1 -1
  46. package/build/middleware.d.ts +1 -1
  47. package/build/middleware.d.ts.map +1 -1
  48. package/build/middleware.js +13 -12
  49. package/build/middleware.js.map +1 -1
  50. package/build/schemas.d.ts +4 -0
  51. package/build/schemas.d.ts.map +1 -0
  52. package/build/schemas.js +10 -0
  53. package/build/schemas.js.map +1 -0
  54. package/build/types.d.ts +65 -1
  55. package/build/types.d.ts.map +1 -1
  56. package/build/types.local.d.ts +5 -0
  57. package/build/types.local.d.ts.map +1 -0
  58. package/build/types.local.js +2 -0
  59. package/build/types.local.js.map +1 -0
  60. package/build/utils/trusted.d.ts.map +1 -1
  61. package/build/utils/trusted.js +4 -3
  62. package/build/utils/trusted.js.map +1 -1
  63. package/package.json +13 -13
  64. package/src/consts.ts +32 -0
  65. package/src/entity/types.ts +52 -0
  66. package/src/entity.ts +67 -0
  67. package/src/entrypoints.ts +68 -0
  68. package/src/guards/basic-ed25519/consts.local.ts +4 -0
  69. package/src/guards/basic-ed25519/index.ts +1 -0
  70. package/src/guards/basic-ed25519/replay.ts +55 -0
  71. package/src/guards/basic-ed25519/service.ts +45 -26
  72. package/src/guards/basic-ed25519/types.ts +17 -0
  73. package/src/helper.ts +0 -0
  74. package/src/index.ts +4 -1
  75. package/src/middleware.ts +15 -18
  76. package/src/schemas.ts +12 -0
  77. package/src/types.local.ts +5 -0
  78. package/src/types.ts +68 -1
  79. package/src/utils/trusted.ts +5 -4
  80. package/tests/basic-ed25519.spec.ts +99 -0
  81. package/tests/context.ts +1 -2
  82. package/tests/dispatcher.spec.ts +15 -15
  83. package/tests/entity.spec.ts +112 -0
  84. package/src/modules.ts +0 -53
@@ -1,11 +1,12 @@
1
- import { fromPubKey, makeKeyPairModel } from '@owlmeans/basic-keys';
1
+ import { keyHelper, makeKeyPairModel } from '@owlmeans/basic-keys';
2
2
  export const trust = async (context, resource, userName, field = 'name') => {
3
- const trustedUser = await context.resource(resource).load(userName, field);
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,UAAU,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAEnE,MAAM,CAAC,MAAM,KAAK,GAAG,KAAK,EAA0C,OAAU,EAAE,QAAgB,EAAE,QAAgB,EAAE,KAAK,GAAW,MAAM,EAAE,EAAE;IAC5I,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,QAAQ,CAA0B,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IACnG,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,UAAU,CAAC,WAAW,CAAC,UAAW,CAAC,CAAA;IAEvH,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,CAAA;AAC5C,CAAC,CAAA"}
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-rc.8",
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-rc.6",
33
- "@owlmeans/auth-common": "^0.1.18-rc.8",
34
- "@owlmeans/basic-ids": "^0.1.18-rc.6",
35
- "@owlmeans/basic-keys": "^0.1.18-rc.8",
36
- "@owlmeans/client-entrypoint": "^0.1.18-rc.8",
37
- "@owlmeans/context": "^0.1.18-rc.6",
38
- "@owlmeans/entrypoint": "^0.1.18-rc.7",
39
- "@owlmeans/resource": "^0.1.18-rc.7",
40
- "@owlmeans/route": "^0.1.18-rc.6"
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": "workspace:*",
44
- "@owlmeans/test-auth": "^0.1.18-rc.8",
45
- "@types/bun": "^1.3.14",
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
@@ -8,6 +8,15 @@ export const RELY_ACTION_TIMEOUT = 600
8
8
 
9
9
  export const DISPATCHER_PATH = '/dispatcher'
10
10
 
11
+ /**
12
+ * Where the surrogate login window runs.
13
+ *
14
+ * Reserved exactly like {@link DISPATCHER_PATH}: an application must not declare a route of its
15
+ * own here, and a code generator must not produce one. It is user-visible — it is what the popup's
16
+ * address bar shows.
17
+ */
18
+ export const SURROGATE_PATH = '/surrogate'
19
+
11
20
  export const DEF_AUTH_SRV = 'auth'
12
21
 
13
22
  export const DEFAULT_GUARD = DEF_AUTH_SRV
@@ -16,6 +25,19 @@ export const TOKEN_UPDATE = 'auth-token-refresh'
16
25
 
17
26
  export const WEB_API = 'web-auth-api'
18
27
 
28
+ /** Service alias of the organization-entity resolver, when an implementation registers one. */
29
+ export const ENTITY_RESOLVER = 'entity-resolver'
30
+
31
+ /**
32
+ * What an organization slug may look like: a DNS label, lowercase.
33
+ *
34
+ * The constraint is not cosmetic. The same slug is used to compose hostnames, Kubernetes object
35
+ * names and OIDC client ids, so anything that would need sanitising before it could address one of
36
+ * those is rejected at the point it is chosen instead — a slug that survives this pattern passes
37
+ * through every downstream name unchanged.
38
+ */
39
+ export const ENTITY_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$/
40
+
19
41
  export const authApi = {
20
42
  profile: {
21
43
  base: 'api:profile',
@@ -36,3 +58,13 @@ export const authApi = {
36
58
  },
37
59
  }
38
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 ADDED
@@ -0,0 +1,67 @@
1
+ import type { AbstractRequest, ResolvedEntity } from '@owlmeans/entrypoint'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+ import { AuthenFailed, AuthorizationError, authHelper } from '@owlmeans/auth'
4
+ import type { EntityResolverService } from './types.js'
5
+ import { ENTITY_RESOLVER } from './consts.js'
6
+ import type { EntityScope } from './entity/types.js'
7
+
8
+ export const makeEntityScope = (req: AbstractRequest): EntityScope => {
9
+ const entityKeyOf = (): string | undefined =>
10
+ req.entity?.id ?? authHelper.entitySlugOf(req.auth)
11
+
12
+ const requireEntityKey = (): string => {
13
+ const key = entityKeyOf()
14
+ if (key == null || key === '') {
15
+ throw new AuthorizationError()
16
+ }
17
+
18
+ return key
19
+ }
20
+
21
+ const requireEntity = (): ResolvedEntity => {
22
+ if (req.entity == null) {
23
+ throw new AuthorizationError()
24
+ }
25
+
26
+ return req.entity
27
+ }
28
+
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
+ }
37
+
38
+ if (slug == null || !context.hasService(ENTITY_RESOLVER)) {
39
+ return undefined
40
+ }
41
+
42
+ const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(slug)
43
+ if (entity == null) {
44
+ throw new AuthenFailed('entity')
45
+ }
46
+
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
51
+
52
+ return entity
53
+ }
54
+
55
+ return { entityKeyOf, requireEntityKey, requireEntity, attachEntity }
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,4 @@
1
+ import { BED255_NONCE_HEADER, BED255_TIME_HEADER } from './consts.js'
2
+
3
+ export const timeKey = BED255_TIME_HEADER.toLocaleLowerCase()
4
+ export const nonceKey = BED255_NONCE_HEADER.toLocaleLowerCase()
@@ -1,4 +1,5 @@
1
1
 
2
2
  export type * from './types.js'
3
3
  export * from './consts.js'
4
+ export * from './replay.js'
4
5
  export * from './service.js'
@@ -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
- BED255_NONCE_HEADER, BED255_TIME_HEADER, BED255_CASHE_RESOURCE, GUARD_ED25519,
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 cache = (context: Context) => context.hasResource(opts?.cache ?? BED255_CASHE_RESOURCE)
28
- ? context.resource<Resource<ResourceRecord>>(opts?.cache ?? BED255_CASHE_RESOURCE)
29
- : null
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]: req.headers[timeKey], [nonceKey]: req.headers[nonceKey] }
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 req.headers[timeKey] !== 'string') {
106
+ if (typeof timestamp !== 'string') {
96
107
  throw new AuthenPayloadError('timestamp')
97
108
  }
98
109
 
99
- const createdAt = new Date(req.headers[timeKey])
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 (createdAt.getTime() + BED255_SIG_TTL < Date.now()) {
116
+ if (Math.abs(Date.now() - signedAt) > BED255_SIG_TTL) {
102
117
  throw new AuthenPayloadError('expired')
103
118
  }
104
119
 
105
- const nonce = req.headers[nonceKey]
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
- const tried = cache(context)
112
- if (tried != null) {
113
- await tried.create({ id: nonce }, { ttl: BED255_SIG_TTL / 1000 })
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')
@@ -120,7 +139,7 @@ export const makeBasicEd25519Guard = (resource: string, opts?: BasicEd25519Guard
120
139
  token: authorization,
121
140
  userId: trusted.user.id,
122
141
  profileId: trusted.user.id,
123
- entityId: trusted.user.entityId,
142
+ entitySlug: trusted.user.entitySlug,
124
143
  scopes: trusted.user.name != null ? [trusted.user.name] : [],
125
144
  type: AuthroizationType.Ed25519BasicSignature,
126
145
  source: context.cfg.service,
@@ -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/helper.ts ADDED
File without changes
package/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export type * from './types.js'
2
+ export type * from './entity/types.js'
2
3
 
3
- export * from './modules.js'
4
+ export * from './schemas.js'
5
+ export * from './entrypoints.js'
4
6
  export * from './consts.js'
7
+ export * from './entity.js'
5
8
  export * from './middleware.js'
6
9
  export * from './guards/index.js'
package/src/middleware.ts CHANGED
@@ -1,33 +1,33 @@
1
- import type { Middleware } from '@owlmeans/context'
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.call != null) {
14
- const guards = module.getGuards()
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
- // const auth = context.service<GuardService>(service)
21
- const call = module.call
22
- module.call = async (req, res) => {
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
- const [token] = (await Promise.all(guards.map(
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
- await module.resolve()
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 call(req, res)
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
- }
package/src/schemas.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { JSONSchemaType } from 'ajv'
2
+ import type { SurrogateQuery } from './types.js'
3
+
4
+ export const SurrogateQuerySchema: JSONSchemaType<SurrogateQuery> = {
5
+ type: 'object',
6
+ properties: {
7
+ intent: { type: 'string', maxLength: 32, nullable: true },
8
+ next: { type: 'string', maxLength: 2048, nullable: true },
9
+ method: { type: 'string', maxLength: 128, nullable: true },
10
+ },
11
+ additionalProperties: true,
12
+ }
@@ -0,0 +1,5 @@
1
+ import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
2
+
3
+ export interface Perked extends ClientEntrypoint<unknown> {
4
+ _auth_common_middleware_applied?: boolean
5
+ }