@owlmeans/server-auth 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 (218) hide show
  1. package/README.md +216 -38
  2. package/agent-meta/manifest.json +10 -3
  3. package/agent-meta/skills/server-auth/SKILL.md +161 -22
  4. package/agent-meta/skills/supervisor-auth/SKILL.md +128 -0
  5. package/build/actions/service.d.ts +6 -2
  6. package/build/actions/service.d.ts.map +1 -1
  7. package/build/actions/service.js +5 -4
  8. package/build/actions/service.js.map +1 -1
  9. package/build/actions/types.local.d.ts +6 -0
  10. package/build/actions/types.local.d.ts.map +1 -0
  11. package/build/actions/types.local.js +2 -0
  12. package/build/actions/types.local.js.map +1 -0
  13. package/build/entrypoints.d.ts +5 -0
  14. package/build/entrypoints.d.ts.map +1 -0
  15. package/build/entrypoints.js +9 -0
  16. package/build/entrypoints.js.map +1 -0
  17. package/build/index.d.ts +2 -1
  18. package/build/index.d.ts.map +1 -1
  19. package/build/index.js +2 -1
  20. package/build/index.js.map +1 -1
  21. package/build/manager/actions/authenticate.d.ts +8 -0
  22. package/build/manager/actions/authenticate.d.ts.map +1 -0
  23. package/build/manager/actions/authenticate.js +5 -0
  24. package/build/manager/actions/authenticate.js.map +1 -0
  25. package/build/manager/actions/index.d.ts +3 -1
  26. package/build/manager/actions/index.d.ts.map +1 -1
  27. package/build/manager/actions/index.js +3 -1
  28. package/build/manager/actions/index.js.map +1 -1
  29. package/build/manager/actions/init.d.ts +8 -0
  30. package/build/manager/actions/init.d.ts.map +1 -0
  31. package/build/manager/actions/init.js +5 -0
  32. package/build/manager/actions/init.js.map +1 -0
  33. package/build/manager/actions/rely.d.ts +8 -0
  34. package/build/manager/actions/rely.d.ts.map +1 -0
  35. package/build/manager/actions/rely.js +7 -0
  36. package/build/manager/actions/rely.js.map +1 -0
  37. package/build/manager/consts.local.d.ts +2 -0
  38. package/build/manager/consts.local.d.ts.map +1 -0
  39. package/build/manager/consts.local.js +2 -0
  40. package/build/manager/consts.local.js.map +1 -0
  41. package/build/manager/context.d.ts.map +1 -1
  42. package/build/manager/context.js +0 -1
  43. package/build/manager/context.js.map +1 -1
  44. package/build/manager/entrypoints.d.ts +9 -0
  45. package/build/manager/entrypoints.d.ts.map +1 -0
  46. package/build/manager/entrypoints.js +16 -0
  47. package/build/manager/entrypoints.js.map +1 -0
  48. package/build/manager/exports.d.ts +1 -2
  49. package/build/manager/exports.d.ts.map +1 -1
  50. package/build/manager/exports.js +1 -2
  51. package/build/manager/exports.js.map +1 -1
  52. package/build/manager/index.d.ts +1 -1
  53. package/build/manager/index.d.ts.map +1 -1
  54. package/build/manager/index.js +1 -1
  55. package/build/manager/index.js.map +1 -1
  56. package/build/manager/main.d.ts.map +1 -1
  57. package/build/manager/main.js +2 -2
  58. package/build/manager/main.js.map +1 -1
  59. package/build/manager/model.d.ts.map +1 -1
  60. package/build/manager/model.js +71 -32
  61. package/build/manager/model.js.map +1 -1
  62. package/build/manager/plugins/basic-ed25519.d.ts +1 -1
  63. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  64. package/build/manager/plugins/basic-ed25519.js +6 -6
  65. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  66. package/build/manager/plugins/basic-rely.d.ts +1 -1
  67. package/build/manager/plugins/basic-rely.d.ts.map +1 -1
  68. package/build/manager/plugins/basic-rely.js +11 -8
  69. package/build/manager/plugins/basic-rely.js.map +1 -1
  70. package/build/manager/plugins/consts.d.ts +8 -0
  71. package/build/manager/plugins/consts.d.ts.map +1 -0
  72. package/build/manager/plugins/consts.js +9 -0
  73. package/build/manager/plugins/consts.js.map +1 -0
  74. package/build/manager/plugins/export.d.ts +5 -0
  75. package/build/manager/plugins/export.d.ts.map +1 -1
  76. package/build/manager/plugins/export.js +4 -0
  77. package/build/manager/plugins/export.js.map +1 -1
  78. package/build/manager/plugins/index.d.ts +3 -4
  79. package/build/manager/plugins/index.d.ts.map +1 -1
  80. package/build/manager/plugins/index.js +6 -6
  81. package/build/manager/plugins/index.js.map +1 -1
  82. package/build/manager/plugins/re-captcha/consts.d.ts +7 -0
  83. package/build/manager/plugins/re-captcha/consts.d.ts.map +1 -0
  84. package/build/manager/plugins/re-captcha/consts.js +7 -0
  85. package/build/manager/plugins/re-captcha/consts.js.map +1 -0
  86. package/build/manager/plugins/re-captcha/consts.local.d.ts +7 -0
  87. package/build/manager/plugins/re-captcha/consts.local.d.ts.map +1 -0
  88. package/build/manager/plugins/re-captcha/consts.local.js +9 -0
  89. package/build/manager/plugins/re-captcha/consts.local.js.map +1 -0
  90. package/build/manager/plugins/re-captcha/policy.d.ts +3 -0
  91. package/build/manager/plugins/re-captcha/policy.d.ts.map +1 -0
  92. package/build/manager/plugins/re-captcha/policy.js +44 -0
  93. package/build/manager/plugins/re-captcha/policy.js.map +1 -0
  94. package/build/manager/plugins/re-captcha/types.d.ts +79 -0
  95. package/build/manager/plugins/re-captcha/types.d.ts.map +1 -0
  96. package/build/manager/plugins/re-captcha/types.js +2 -0
  97. package/build/manager/plugins/re-captcha/types.js.map +1 -0
  98. package/build/manager/plugins/re-captcha/verifier.d.ts +12 -0
  99. package/build/manager/plugins/re-captcha/verifier.d.ts.map +1 -0
  100. package/build/manager/plugins/re-captcha/verifier.js +62 -0
  101. package/build/manager/plugins/re-captcha/verifier.js.map +1 -0
  102. package/build/manager/plugins/re-captcha.d.ts +10 -1
  103. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  104. package/build/manager/plugins/re-captcha.js +50 -37
  105. package/build/manager/plugins/re-captcha.js.map +1 -1
  106. package/build/manager/plugins/supervisor.d.ts +1 -2
  107. package/build/manager/plugins/supervisor.d.ts.map +1 -1
  108. package/build/manager/plugins/supervisor.js +6 -6
  109. package/build/manager/plugins/supervisor.js.map +1 -1
  110. package/build/manager/plugins/types.d.ts +22 -14
  111. package/build/manager/plugins/types.d.ts.map +1 -1
  112. package/build/manager/plugins/utils.d.ts +5 -4
  113. package/build/manager/plugins/utils.d.ts.map +1 -1
  114. package/build/manager/plugins/utils.js +20 -15
  115. package/build/manager/plugins/utils.js.map +1 -1
  116. package/build/manager/rely/flow.d.ts.map +1 -1
  117. package/build/manager/rely/flow.js +13 -11
  118. package/build/manager/rely/flow.js.map +1 -1
  119. package/build/manager/rely.d.ts.map +1 -1
  120. package/build/manager/rely.js.map +1 -1
  121. package/build/manager/supervisor.d.ts +4 -45
  122. package/build/manager/supervisor.d.ts.map +1 -1
  123. package/build/manager/supervisor.js +5 -5
  124. package/build/manager/supervisor.js.map +1 -1
  125. package/build/manager/types.d.ts +41 -1
  126. package/build/manager/types.d.ts.map +1 -1
  127. package/build/manager/types.local.d.ts +4 -0
  128. package/build/manager/types.local.d.ts.map +1 -0
  129. package/build/manager/types.local.js +2 -0
  130. package/build/manager/types.local.js.map +1 -0
  131. package/build/manager/utils/trusted.d.ts.map +1 -1
  132. package/build/manager/utils/trusted.js.map +1 -1
  133. package/build/model/rely/consumer.d.ts +3 -1
  134. package/build/model/rely/consumer.d.ts.map +1 -1
  135. package/build/model/rely/consumer.js.map +1 -1
  136. package/build/model/rely/peer.d.ts.map +1 -1
  137. package/build/model/rely/peer.js +7 -7
  138. package/build/model/rely/peer.js.map +1 -1
  139. package/build/model/rely/provider.d.ts +3 -1
  140. package/build/model/rely/provider.d.ts.map +1 -1
  141. package/build/model/rely/provider.js.map +1 -1
  142. package/build/re-captcha/guard.d.ts +12 -0
  143. package/build/re-captcha/guard.d.ts.map +1 -0
  144. package/build/re-captcha/guard.js +43 -0
  145. package/build/re-captcha/guard.js.map +1 -0
  146. package/build/re-captcha/index.d.ts +4 -0
  147. package/build/re-captcha/index.d.ts.map +1 -0
  148. package/build/re-captcha/index.js +3 -0
  149. package/build/re-captcha/index.js.map +1 -0
  150. package/build/re-captcha/token.d.ts +6 -0
  151. package/build/re-captcha/token.d.ts.map +1 -0
  152. package/build/re-captcha/token.js +74 -0
  153. package/build/re-captcha/token.js.map +1 -0
  154. package/build/re-captcha/types.d.ts +44 -0
  155. package/build/re-captcha/types.d.ts.map +1 -0
  156. package/build/re-captcha/types.js +2 -0
  157. package/build/re-captcha/types.js.map +1 -0
  158. package/build/service.d.ts +2 -3
  159. package/build/service.d.ts.map +1 -1
  160. package/build/service.js +136 -13
  161. package/build/service.js.map +1 -1
  162. package/build/types.local.d.ts +4 -0
  163. package/build/types.local.d.ts.map +1 -0
  164. package/build/types.local.js +2 -0
  165. package/build/types.local.js.map +1 -0
  166. package/package.json +33 -30
  167. package/src/actions/service.ts +10 -12
  168. package/src/actions/types.local.ts +6 -0
  169. package/src/entrypoints.ts +12 -0
  170. package/src/index.ts +2 -2
  171. package/src/manager/README.md +14 -18
  172. package/src/manager/actions/authenticate.ts +12 -0
  173. package/src/manager/actions/index.ts +3 -1
  174. package/src/manager/actions/init.ts +12 -0
  175. package/src/manager/actions/rely.ts +12 -0
  176. package/src/manager/consts.local.ts +1 -0
  177. package/src/manager/context.ts +0 -2
  178. package/src/manager/entrypoints.ts +17 -0
  179. package/src/manager/exports.ts +1 -2
  180. package/src/manager/index.ts +1 -1
  181. package/src/manager/main.ts +2 -2
  182. package/src/manager/model.ts +82 -41
  183. package/src/manager/plugins/basic-ed25519.ts +8 -8
  184. package/src/manager/plugins/basic-rely.ts +14 -9
  185. package/src/manager/plugins/consts.ts +7 -0
  186. package/src/manager/plugins/export.ts +5 -0
  187. package/src/manager/plugins/index.ts +10 -12
  188. package/src/manager/plugins/re-captcha/consts.local.ts +8 -0
  189. package/src/manager/plugins/re-captcha/consts.ts +8 -0
  190. package/src/manager/plugins/re-captcha/policy.ts +56 -0
  191. package/src/manager/plugins/re-captcha/types.ts +85 -0
  192. package/src/manager/plugins/re-captcha/verifier.ts +74 -0
  193. package/src/manager/plugins/re-captcha.ts +58 -43
  194. package/src/manager/plugins/supervisor.ts +9 -11
  195. package/src/manager/plugins/types.ts +23 -14
  196. package/src/manager/plugins/utils.ts +24 -14
  197. package/src/manager/rely/flow.ts +14 -11
  198. package/src/manager/rely.ts +2 -5
  199. package/src/manager/supervisor.ts +8 -54
  200. package/src/manager/types.local.ts +5 -0
  201. package/src/manager/types.ts +46 -1
  202. package/src/model/rely/consumer.ts +3 -1
  203. package/src/model/rely/peer.ts +8 -8
  204. package/src/model/rely/provider.ts +3 -1
  205. package/src/re-captcha/guard.ts +52 -0
  206. package/src/re-captcha/index.ts +3 -0
  207. package/src/re-captcha/token.ts +89 -0
  208. package/src/re-captcha/types.ts +45 -0
  209. package/src/service.ts +133 -19
  210. package/src/types.local.ts +5 -0
  211. package/tests/context.ts +3 -2
  212. package/tests/re-captcha.spec.ts +298 -0
  213. package/tests/replay-policy.spec.ts +36 -0
  214. package/tests/supervisor.spec.ts +6 -4
  215. package/build/.gitkeep +0 -0
  216. package/src/manager/actions/manager.ts +0 -23
  217. package/src/manager/modules.ts +0 -29
  218. package/src/modules.ts +0 -17
@@ -1,18 +1,26 @@
1
1
  import type { AuthModel, AppConfig, AppContext } from './types.js'
2
- import { AuthenFailed, AuthenPayloadError } from '@owlmeans/auth'
3
- import type { AuthCredentials } from '@owlmeans/auth'
4
- import { getPlugin } from './plugins/utils.js'
2
+ import { AuthenFailed, AuthenPayloadError, AuthError, AuthUnavailable, authHelper, type AuthCredentials } from '@owlmeans/auth'
3
+ import { logger } from '@owlmeans/log'
4
+ import { type EntityResolverService, ENTITY_RESOLVER } from '@owlmeans/auth-common'
5
+ import { authPluginHelper } from './plugins/utils.js'
5
6
  import { AUTH_CACHE, AUTHEN_TIMEFRAME } from '../consts.js'
6
- import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
7
- import type { EnvelopeModel } from '@owlmeans/basic-envelope'
7
+ import { EnvelopeKind, makeEnvelopeModel, type EnvelopeModel } from '@owlmeans/basic-envelope'
8
8
  import { createRelyFlow } from './rely/flow.js'
9
9
  import { trusted } from './utils/trusted.js'
10
- import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
11
10
  import type { Resource } from '@owlmeans/resource'
12
11
  import type { AuthSpent } from '../types.js'
12
+ import { AuthChallengeReplayPolicy } from './plugins/consts.js'
13
+ import type { Context } from './types.local.js'
13
14
 
14
- type Config = ServerConfig
15
- type Context = ServerContext<Config>
15
+
16
+ const log = logger('server-auth')
17
+
18
+ /** An account id as a log may carry it: an e-mail is reduced to its domain. */
19
+ const accountOf = (userId: string | undefined): string | undefined =>
20
+ userId == null ? undefined : userId.includes('@') ? `*@${userId.slice(userId.lastIndexOf('@') + 1)}` : userId
21
+
22
+ const compact = <T extends Record<string, unknown>>(value: T): Partial<T> =>
23
+ Object.fromEntries(Object.entries(value).filter(([, entry]) => entry != null)) as Partial<T>
16
24
 
17
25
  export const makeAuthModel = (context: AppContext<AppConfig>): AuthModel => {
18
26
  const cache = (context: Context): Resource<AuthSpent> =>
@@ -20,7 +28,7 @@ export const makeAuthModel = (context: AppContext<AppConfig>): AuthModel => {
20
28
 
21
29
  const model: AuthModel = {
22
30
  init: async request => {
23
- const plugin = await getPlugin(request.type, context)
31
+ const plugin = await authPluginHelper.getPlugin(request.type, context)
24
32
  const response = await plugin.init(request)
25
33
 
26
34
  const envelope = makeEnvelopeModel(plugin.type)
@@ -41,46 +49,79 @@ export const makeAuthModel = (context: AppContext<AppConfig>): AuthModel => {
41
49
  },
42
50
 
43
51
  authenticate: async credential => {
44
- const [trustedUser, keyPair] = await trusted(context)
45
- const envelope: EnvelopeModel = makeEnvelopeModel(credential.challenge, EnvelopeKind.Wrap)
46
- if (!await envelope.verify(keyPair)) {
47
- throw new AuthenFailed('challenge')
48
- }
49
-
50
- const msg: string = envelope.message(true)
51
-
52
+ // The one place every manager sign-in method (each plugin) is decided — logged once here.
53
+ const method = credential.type
52
54
  try {
53
- await cache(context).create({ id: msg }, { ttl: (envelope.envelope.ttl ?? AUTHEN_TIMEFRAME) / 1000 })
54
- } catch (e) {
55
- const error = new AuthenFailed('challenge')
56
- if (e instanceof Error) {
57
- error.oiriginalStack = `${e} : ${e.stack}`
55
+ const [trustedUser, keyPair] = await trusted(context)
56
+ const envelope: EnvelopeModel = makeEnvelopeModel(credential.challenge, EnvelopeKind.Wrap)
57
+ if (!await envelope.verify(keyPair)) {
58
+ throw new AuthenFailed('challenge')
58
59
  }
59
- throw error
60
- }
61
60
 
62
- if (credential.userId == null) {
63
- throw new AuthenPayloadError('userId')
64
- }
61
+ const msg: string = envelope.message(true)
62
+
63
+ const plugin = await authPluginHelper.getPlugin(envelope.type(), context)
65
64
 
66
- const plugin = await getPlugin(envelope.type(), context)
65
+ if (plugin.challengeReplayPolicy !== AuthChallengeReplayPolicy.Plugin) {
66
+ try {
67
+ await cache(context).create({ id: msg }, { ttl: (envelope.envelope.ttl ?? AUTHEN_TIMEFRAME) / 1000 })
68
+ } catch (e) {
69
+ const error = new AuthenFailed('challenge')
70
+ if (e instanceof Error) {
71
+ error.oiriginalStack = `${e} : ${e.stack}`
72
+ }
73
+ throw error
74
+ }
75
+ }
67
76
 
68
- // @TODO this token PROBABLY needs to be registered somewhere and rechecked
69
- // from time to time to make sure that the session is not deleted
70
- // or permission hasn't changed.
71
- // Alternative solution is to have permissions cached and cleaned
72
- // when some broadcast message is sent from authentication system.
77
+ if (credential.userId == null) {
78
+ throw new AuthenPayloadError('userId')
79
+ }
73
80
 
74
- const challenge = credential.challenge
75
- const { token } = await plugin.authenticate(Object.assign(credential, { challenge: msg }))
76
- credential.challenge = token === '' ? challenge : token
81
+ // @TODO this token PROBABLY needs to be registered somewhere and rechecked
82
+ // from time to time to make sure that the session is not deleted
83
+ // or permission hasn't changed.
84
+ // Alternative solution is to have permissions cached and cleaned
85
+ // when some broadcast message is sent from authentication system.
86
+
87
+ const challenge = credential.challenge
88
+ const { token } = await plugin.authenticate(Object.assign(credential, { challenge: msg }))
89
+ credential.challenge = token === '' ? challenge : token
90
+
91
+ // Plugins accept whatever the caller typed — a current slug, a slug the organization has
92
+ // since retired, or (for a token minted before slugs existed) its stable key. The token is
93
+ // signed once and read for as long as it lives, so the value is canonicalized here rather
94
+ // than at every place that later reads it back.
95
+ const entitySlug = authHelper.entitySlugOf(credential)
96
+ if (entitySlug != null && context.hasService(ENTITY_RESOLVER)) {
97
+ const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(entitySlug)
98
+ if (entity == null) {
99
+ throw new AuthenFailed('entity')
100
+ }
101
+ credential.entitySlug = entity.slug
102
+ }
77
103
 
78
- credential.credential = trustedUser.id
104
+ credential.credential = trustedUser.id
79
105
 
80
- return {
81
- token: await makeEnvelopeModel<AuthCredentials>(credential.type)
82
- .send(credential, AUTHEN_TIMEFRAME)
83
- .sign(keyPair, EnvelopeKind.Token)
106
+ const result = {
107
+ token: await makeEnvelopeModel<AuthCredentials>(credential.type)
108
+ .send(credential, AUTHEN_TIMEFRAME)
109
+ .sign(keyPair, EnvelopeKind.Token)
110
+ }
111
+ log.info('Signed in', compact({
112
+ method, accountId: accountOf(credential.userId), profileId: credential.profileId,
113
+ entitySlug: credential.entitySlug,
114
+ }), { event: 'auth.signed-in' })
115
+
116
+ return result
117
+ } catch (error) {
118
+ // An outage (`AuthUnavailable`) is not a refusal; it propagates and is logged as a failure.
119
+ if (error instanceof AuthError && !(error instanceof AuthUnavailable)) {
120
+ log.warn('Sign-in refused', compact({
121
+ method, accountId: accountOf(credential.userId), reason: error.message,
122
+ }), { event: 'auth.refused' })
123
+ }
124
+ throw error
84
125
  }
85
126
  },
86
127
 
@@ -1,18 +1,18 @@
1
1
  import { AuthenticationType, AuthenFailed, ALL_SCOPES, AuthRole } from '@owlmeans/auth'
2
2
  import type { AuthPlugin } from './types.js'
3
- import { assertType } from './utils.js'
4
- import { randomBytes } from '@noble/hashes/utils'
3
+ import { authPluginHelper } from './utils.js'
4
+ import { randomBytes } from '@noble/hashes/utils.js'
5
5
  import { base64 } from '@scure/base'
6
- import { fromPubKey } from '@owlmeans/basic-keys'
6
+ import { keyHelper } from '@owlmeans/basic-keys'
7
7
  import type { AppConfig, AppContext } from '../types.js'
8
8
  import { TRUSTED } from '@owlmeans/config'
9
9
  import type { TrustedRecord } from '@owlmeans/auth-common'
10
10
 
11
- export const basicEd25519 = <C extends AppConfig, T extends AppContext<C>>(context: T): AuthPlugin => {
11
+ export const makeBasicEd25519Plugin = <C extends AppConfig, T extends AppContext<C>>(context: T): AuthPlugin => {
12
12
  const plugin: AuthPlugin = {
13
13
  type: AuthenticationType.BasicEd25519,
14
14
  init: async request => {
15
- assertType(request.type, plugin)
15
+ authPluginHelper.assertType(request.type, plugin)
16
16
 
17
17
  const challenge = base64.encode(randomBytes(32))
18
18
 
@@ -23,8 +23,8 @@ export const basicEd25519 = <C extends AppConfig, T extends AppContext<C>>(conte
23
23
  /**
24
24
  * @TODO We need to find users in some db not just in static
25
25
  */
26
- const systemUser = await context.getConfigResource(TRUSTED)
27
- .load<TrustedRecord>(credential.userId)
26
+ const systemUser = await context.getConfigResource<TrustedRecord>(TRUSTED)
27
+ .load(credential.userId)
28
28
 
29
29
  if (systemUser == null) {
30
30
  throw new AuthenFailed()
@@ -34,7 +34,7 @@ export const basicEd25519 = <C extends AppConfig, T extends AppContext<C>>(conte
34
34
  throw new SyntaxError('System user missconfigured')
35
35
  }
36
36
 
37
- const key = fromPubKey(systemUser.credential)
37
+ const key = keyHelper.fromPubKey(systemUser.credential)
38
38
 
39
39
  if (!await key.verify(credential.challenge, credential.credential)) {
40
40
  throw new AuthenFailed(`challenge:${plugin.type}`)
@@ -7,17 +7,21 @@ import { trusted } from '../utils/trusted.js'
7
7
  import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
8
8
  import { AUTH_CACHE } from '../../consts.js'
9
9
  import { ResourceError } from '@owlmeans/resource'
10
+ import type { Unsubscribe } from '@owlmeans/resource'
10
11
  import { RELY_CALL_TIMEOUT, RELY_PIN_PERFIX, RELY_TOKEN_PREFIX } from '@owlmeans/auth-common'
12
+ import { logger } from '@owlmeans/log'
11
13
 
12
- const _subscriptions: Record<string, CallableFunction> = {}
13
- export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
14
+ const log = logger('server-auth:rely')
15
+
16
+ const _subscriptions: Record<string, Unsubscribe> = {}
17
+ export const makeBasicRelyPlugin = (context: AppContext, type?: string): AuthPlugin => {
14
18
  const plugin: AuthPlugin = {
15
19
  type: type ?? AuthenticationType.RelyHandshake,
16
20
 
17
21
  // This method is used by consumer and by provider (wallet)
18
22
  // to initialize shared authentication process.
19
- // The idea is that both subscribe to wait for a handshake value
20
- // from the peer, that in fact is a subscription to a redis message tunnel.
23
+ // The idea is that both watch the record holding their own pin/token and wake
24
+ // up when the peer writes its handshake value into it.
21
25
  init: async (request: RelyAllowanceRequest): Promise<AllowanceResponse> => {
22
26
  const [, keyPair] = await trusted(context)
23
27
  const tunnel = context.resource<AuthRedisResource>(AUTH_CACHE)
@@ -41,8 +45,8 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
41
45
 
42
46
  if (request.provideRely != null) {
43
47
  await Promise.all([pin, token].filter(key => key != null).map(async key => {
44
- _subscriptions[key] = await tunnel.subscribe(async value => {
45
- if (value.challenge != null) {
48
+ _subscriptions[key] = await tunnel.watch(key, async value => {
49
+ if (value?.challenge != null) {
46
50
  const rely = makeEnvelopeModel<RelyToken>(value.challenge, EnvelopeKind.Wrap)
47
51
  if (await rely.verify(keyPair)) {
48
52
  [pin, token].filter(key => key != null).forEach(key => {
@@ -54,7 +58,7 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
54
58
  await request.provideRely?.(rely.message(), relyMsg, true)
55
59
  }
56
60
  }
57
- }, { /*ttl: RELY_CALL_TIMEOUT,*/ key, once: true })
61
+ }, { once: true })
58
62
 
59
63
  // @TODO they need to be killed on close - but actually live a little bit longer
60
64
  // then timeout for them is ok
@@ -74,7 +78,7 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
74
78
  return response
75
79
  } catch (e) {
76
80
  if (e instanceof ResourceError) {
77
- console.error('COLISION ON TRY TO CREATE RELY PIN\TOKEN...', e)
81
+ log.warn('Rely pin/token collision; retrying', e)
78
82
  } else {
79
83
  throw e
80
84
  }
@@ -117,7 +121,8 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
117
121
  rely = envelop.message()
118
122
  } catch (e) {
119
123
  if (e instanceof ResourceError) {
120
- console.error(e)
124
+ // The peer's pin/token is unknown; the refusal itself is logged where sign-in is decided.
125
+ log.debug('Rely peer lookup failed', e)
121
126
  throw new AuthorizationError('rely')
122
127
  } else {
123
128
  throw e
@@ -0,0 +1,7 @@
1
+ /** Ownership of atomic replay protection for a verified authentication challenge. */
2
+ export enum AuthChallengeReplayPolicy {
3
+ /** Default: the auth manager consumes the challenge before plugin authentication. */
4
+ Manager = 'manager',
5
+ /** The plugin owns bounded attempts and atomic success consumption. */
6
+ Plugin = 'plugin',
7
+ }
@@ -1,5 +1,10 @@
1
1
 
2
2
  export type * from './types.js'
3
+ export type * from './re-captcha/types.js'
4
+ export * from './consts.js'
5
+ export * from './re-captcha/consts.js'
6
+ export * from './re-captcha/policy.js'
7
+ export * from './re-captcha/verifier.js'
3
8
  export * from './basic-ed25519.js'
4
9
  export * from './re-captcha.js'
5
10
  export * from './basic-rely.js'
@@ -1,21 +1,19 @@
1
1
  import { AuthenticationType } from '@owlmeans/auth'
2
- import type { AuthPlugin } from './types.js'
3
- import { basicEd25519 } from './basic-ed25519.js'
4
- import type { AppContext, AppConfig } from '../types.js'
5
- import { reCaptcha } from './re-captcha.js'
6
- import { basicRely } from './basic-rely.js'
2
+ import type { AuthPluginFactory } from './types.js'
3
+ import { makeBasicEd25519Plugin } from './basic-ed25519.js'
4
+ import { makeReCaptchaPlugin } from './re-captcha.js'
5
+ import { makeBasicRelyPlugin } from './basic-rely.js'
7
6
 
8
- export const plugins: Record<string, <C extends AppConfig, T extends AppContext<C>>(context: T) => AuthPlugin> = {}
7
+ export const plugins: Record<string, AuthPluginFactory> = {}
9
8
 
10
- plugins[AuthenticationType.BasicEd25519] = basicEd25519 as typeof plugins[keyof typeof plugins]
11
- plugins[AuthenticationType.ReCaptcha] = reCaptcha as typeof plugins[keyof typeof plugins]
12
- plugins[AuthenticationType.RelyHandshake] = basicRely as typeof plugins[keyof typeof plugins]
9
+ plugins[AuthenticationType.BasicEd25519] = makeBasicEd25519Plugin as AuthPluginFactory
10
+ plugins[AuthenticationType.ReCaptcha] = makeReCaptchaPlugin as AuthPluginFactory
11
+ plugins[AuthenticationType.RelyHandshake] = makeBasicRelyPlugin as AuthPluginFactory
13
12
 
14
13
  /** Register an external AuthPlugin factory under a custom type string. */
15
14
  export const registerPlugin = (
16
15
  type: string,
17
- factory: <C extends AppConfig, T extends AppContext<C>>(context: T) => AuthPlugin
16
+ factory: AuthPluginFactory
18
17
  ): void => {
19
- plugins[type] = factory as typeof plugins[keyof typeof plugins]
18
+ plugins[type] = factory
20
19
  }
21
-
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The only credential fields a reCAPTCHA guest token keeps. Everything else the caller posted —
3
+ * a profile, an organization (`entitySlug` or `entityId`), permissions, groups, an expiry, a
4
+ * `source` — is dropped: the token proves a solved challenge, never an identity.
5
+ */
6
+ export const RECAPTCHA_CREDENTIAL_FIELDS: readonly string[] = Object.freeze([
7
+ 'type', 'role', 'userId', 'scopes', 'challenge', 'credential',
8
+ ])
@@ -0,0 +1,8 @@
1
+ /** The service alias the reCAPTCHA plugin resolves its verifier by. */
2
+ export const RECAPTCHA_VERIFIER = 're-captcha-verifier'
3
+
4
+ /** Google's siteverify endpoint — always https. */
5
+ export const RECAPTCHA_SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'
6
+
7
+ /** How long the default verifier waits for Google, in ms. */
8
+ export const RECAPTCHA_VERIFY_TIMEOUT = 10_000
@@ -0,0 +1,56 @@
1
+ import { AuthenFailed } from '@owlmeans/auth'
2
+ import { PluginMissconfigured } from '@owlmeans/config'
3
+ import type { ReCaptchaPluginConfig, ReCaptchaPolicyModel, ReCaptchaResponse } from './types.js'
4
+
5
+ /** A comma- or space-separated config string as its entries; `''` and absent are no entries. */
6
+ const listOf = (value: unknown): string[] =>
7
+ typeof value === 'string' ? value.split(/[\s,]+/).map(entry => entry.trim()).filter(entry => entry !== '') : []
8
+
9
+ /** A host as it is compared: lowercased, without a trailing dot or a leading `*.`/`.`. */
10
+ const hostOf = (value: string): string => value.trim().toLowerCase().replace(/\.$/, '').replace(/^\*?\./, '')
11
+
12
+ export const makeReCaptchaPolicyModel = (record: ReCaptchaPluginConfig): ReCaptchaPolicyModel => {
13
+ const hostnames = (): string[] => listOf(record.hostnames).map(hostOf).filter(host => host !== '')
14
+
15
+ const minScore = (): number | null => {
16
+ const value = record.minScore
17
+ if (value == null || (typeof value === 'string' && value.trim() === '')) {
18
+ return null
19
+ }
20
+ const score = typeof value === 'number' ? value : Number(value)
21
+ if (!Number.isFinite(score) || score < 0 || score > 1) {
22
+ throw new PluginMissconfigured('minScore')
23
+ }
24
+
25
+ return score
26
+ }
27
+
28
+ const actions = (): string[] => listOf(record.actions)
29
+
30
+ const assert = (response: ReCaptchaResponse): void => {
31
+ if (response.success !== true) {
32
+ const codes = Array.isArray(response['error-codes']) ? response['error-codes'] : []
33
+ throw new AuthenFailed('recaptcha:' + (codes.length > 0 ? codes.join(',') : 'unknown'))
34
+ }
35
+
36
+ const allowed = hostnames()
37
+ if (allowed.length > 0) {
38
+ const host = typeof response.hostname === 'string' ? hostOf(response.hostname) : ''
39
+ if (host === '' || !allowed.some(entry => host === entry || host.endsWith(`.${entry}`))) {
40
+ throw new AuthenFailed('recaptcha:hostname')
41
+ }
42
+ }
43
+
44
+ const floor = minScore()
45
+ if (floor != null && typeof response.score === 'number' && response.score < floor) {
46
+ throw new AuthenFailed('recaptcha:score')
47
+ }
48
+
49
+ const named = actions()
50
+ if (named.length > 0 && (typeof response.action !== 'string' || !named.includes(response.action))) {
51
+ throw new AuthenFailed('recaptcha:action')
52
+ }
53
+ }
54
+
55
+ return { record, hostnames, minScore, actions, assert }
56
+ }
@@ -0,0 +1,85 @@
1
+ import type { PluginConfig } from '@owlmeans/config'
2
+ import type { InitializedService } from '@owlmeans/context'
3
+
4
+ /** Google's siteverify answer, for a checkbox (v2) or a score-based (v3) token. */
5
+ export interface ReCaptchaResponse {
6
+ success: boolean
7
+ /** When the challenge was solved, as an ISO date-time string. */
8
+ challenge_ts: string
9
+ /** The host of the page the token was solved on. */
10
+ hostname: string
11
+ /** Score-based keys only: 0.0 (a bot) … 1.0 (a person). */
12
+ score?: number
13
+ /** Score-based keys only: the action the page named when it asked for the token. */
14
+ action?: string
15
+ 'error-codes'?: string[]
16
+ }
17
+
18
+ /** The siteverify form: the key's secret, the token the browser got, optionally the browser's IP. */
19
+ export interface ReCaptchaRequest {
20
+ secret: string
21
+ response: string
22
+ remoteip?: string
23
+ }
24
+
25
+ /**
26
+ * Asks Google whether a reCAPTCHA token is good. The plugin resolves it by `RECAPTCHA_VERIFIER`, so a
27
+ * test (or a proxy) registers its own under that alias; without one, the default siteverify call runs.
28
+ */
29
+ export interface ReCaptchaVerifierService extends InitializedService {
30
+ /**
31
+ * Google's answer for one token. A refusal is an answer (`success: false`), not an error.
32
+ *
33
+ * @throws {AuthUnavailable} when Google cannot be asked or answers something unreadable.
34
+ */
35
+ verify: (request: ReCaptchaRequest) => Promise<ReCaptchaResponse>
36
+ }
37
+
38
+ /** The one call the default verifier makes — `fetch`, or a stand-in a test injects. */
39
+ export interface ReCaptchaFetch {
40
+ (url: string, init: RequestInit): Promise<Response>
41
+ }
42
+
43
+ export interface ReCaptchaVerifierOptions {
44
+ /** The siteverify address; must be https. Default `RECAPTCHA_SITEVERIFY_URL`. */
45
+ url?: string
46
+ /** Default: the global `fetch`. */
47
+ fetch?: ReCaptchaFetch
48
+ /** Milliseconds before the call is abandoned. Default `RECAPTCHA_VERIFY_TIMEOUT`. */
49
+ timeout?: number
50
+ }
51
+
52
+ /**
53
+ * The `MOD_RECAPTCHA` plugin record of the server config: the secret in `value` plus the policy.
54
+ * Every policy field is a string as a mounted config file delivers it; `''` or absent = no check.
55
+ */
56
+ export interface ReCaptchaPluginConfig extends PluginConfig {
57
+ /** Hosts a token may be solved on, comma- or space-separated; a subdomain of an entry passes. */
58
+ hostnames?: string
59
+ /** The lowest score a score-based token may carry (0…1); a checkbox token has none and passes. */
60
+ minScore?: string | number
61
+ /** The actions a token may be issued for, comma- or space-separated; a token naming none fails. */
62
+ actions?: string
63
+ }
64
+
65
+ /** The policy one `MOD_RECAPTCHA` record states, applied to Google's answer. */
66
+ export interface ReCaptchaPolicyModel {
67
+ readonly record: ReCaptchaPluginConfig
68
+ /** The allowed hosts, lowercased; empty = any host. */
69
+ hostnames: () => string[]
70
+ /**
71
+ * The lowest accepted score, or `null` for no score check.
72
+ *
73
+ * @throws {PluginMissconfigured} when the record's value is not a number between 0 and 1.
74
+ */
75
+ minScore: () => number | null
76
+ /** The allowed actions; empty = any action. */
77
+ actions: () => string[]
78
+ /**
79
+ * Accept Google's answer or refuse it, in this order: success, hostname, score, action.
80
+ *
81
+ * @throws {AuthenFailed} `recaptcha:<error-codes>`, `recaptcha:hostname`, `recaptcha:score`,
82
+ * `recaptcha:action`.
83
+ */
84
+ assert: (response: ReCaptchaResponse) => void
85
+ }
@@ -0,0 +1,74 @@
1
+ import { AuthUnavailable } from '@owlmeans/auth'
2
+ import { createService } from '@owlmeans/context'
3
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
4
+ import { logger } from '@owlmeans/log'
5
+ import { RECAPTCHA_SITEVERIFY_URL, RECAPTCHA_VERIFIER, RECAPTCHA_VERIFY_TIMEOUT } from './consts.js'
6
+ import type { ReCaptchaResponse, ReCaptchaVerifierOptions, ReCaptchaVerifierService } from './types.js'
7
+
8
+ const log = logger('server-auth:re-captcha')
9
+
10
+ /**
11
+ * The default verifier: one form POST to Google's siteverify. It holds no state and never touches
12
+ * its context, so the plugin may also run it unregistered.
13
+ *
14
+ * @throws {SyntaxError} for a siteverify address that is not https.
15
+ */
16
+ export const createReCaptchaVerifierService = (
17
+ alias: string = RECAPTCHA_VERIFIER, options: ReCaptchaVerifierOptions = {}
18
+ ): ReCaptchaVerifierService => {
19
+ const url = options.url ?? RECAPTCHA_SITEVERIFY_URL
20
+ if (!url.startsWith('https://')) {
21
+ throw new SyntaxError(`reCAPTCHA siteverify must be https: ${url}`)
22
+ }
23
+ const timeout = options.timeout ?? RECAPTCHA_VERIFY_TIMEOUT
24
+
25
+ /** The answer, when it has the shape of one. */
26
+ const readAnswer = async (response: Response): Promise<ReCaptchaResponse | null> => {
27
+ try {
28
+ const answer = await response.json() as ReCaptchaResponse | null
29
+ return answer != null && typeof answer === 'object' && typeof answer.success === 'boolean' ? answer : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ return createService<ReCaptchaVerifierService>(alias, {
36
+ verify: async request => {
37
+ const body = new URLSearchParams({ secret: request.secret, response: request.response })
38
+ if (request.remoteip != null && request.remoteip !== '') {
39
+ body.set('remoteip', request.remoteip)
40
+ }
41
+
42
+ const send = options.fetch ?? fetch
43
+ let response: Response
44
+ try {
45
+ response = await send(url, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
48
+ body,
49
+ signal: AbortSignal.timeout(timeout),
50
+ })
51
+ } catch (error) {
52
+ log.warn('reCAPTCHA siteverify unreachable', { error }, { event: 'auth.recaptcha.unavailable' })
53
+ throw new AuthUnavailable('recaptcha')
54
+ }
55
+
56
+ const answer = response.ok ? await readAnswer(response) : null
57
+ if (answer == null) {
58
+ log.warn('reCAPTCHA siteverify answered unusably', { status: response.status }, { event: 'auth.recaptcha.unavailable' })
59
+ throw new AuthUnavailable('recaptcha')
60
+ }
61
+
62
+ return answer
63
+ },
64
+ })
65
+ }
66
+
67
+ /** Register a verifier (the default siteverify call unless `options` say otherwise). */
68
+ export const appendReCaptchaVerifierService = <C extends BasicConfig, T extends BasicContext<C>>(
69
+ context: T, alias: string = RECAPTCHA_VERIFIER, options?: ReCaptchaVerifierOptions
70
+ ): T => {
71
+ context.registerService(createReCaptchaVerifierService(alias, options))
72
+
73
+ return context
74
+ }