@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
@@ -0,0 +1,89 @@
1
+ import { AuthenticationType, AuthRole, AuthUnavailable, GUEST_ID } from '@owlmeans/auth'
2
+ import type { AuthCredentials } from '@owlmeans/auth'
3
+ import { extractAuthToken, trust } from '@owlmeans/auth-common/utils'
4
+ import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
5
+ import type { EnvelopeModel } from '@owlmeans/basic-envelope'
6
+ import { TRUSTED } from '@owlmeans/config'
7
+ import { memoHelper } from '@owlmeans/context'
8
+ import { logger } from '@owlmeans/log'
9
+ import { RecordExists } from '@owlmeans/resource'
10
+ import type { Resource } from '@owlmeans/resource'
11
+ import { AUTH_CACHE, AUTH_SRV_KEY, AUTHEN_TIMEFRAME } from '../consts.js'
12
+ import type { AuthSpent } from '../types.js'
13
+ import type { Config, Context } from '../types.local.js'
14
+ import type { ReCaptchaCarrier, ReCaptchaGuest, ReCaptchaTokenHelper } from './types.js'
15
+
16
+ const log = logger('server-auth:re-captcha')
17
+
18
+ export const makeReCaptchaTokenHelper = (context: Context): ReCaptchaTokenHelper => {
19
+ const refuse = (reason: string): null => {
20
+ log.debug('reCAPTCHA token refused', { reason }, { event: 'auth.refused' })
21
+ return null
22
+ }
23
+
24
+ const verify = async (token: string): Promise<ReCaptchaGuest | null> => {
25
+ let envelope: EnvelopeModel<AuthCredentials>
26
+ try {
27
+ envelope = makeEnvelopeModel<AuthCredentials>(token, EnvelopeKind.Token)
28
+ } catch {
29
+ return refuse('token')
30
+ }
31
+ if (envelope.type() !== AuthenticationType.ReCaptcha) {
32
+ return refuse('type')
33
+ }
34
+ // A guest token lives no longer than its spend record does, so it can never be spent twice.
35
+ const ttl = envelope.envelope.ttl
36
+ if (ttl == null || ttl > AUTHEN_TIMEFRAME) {
37
+ return refuse('ttl')
38
+ }
39
+
40
+ const signer = await trust<Config, Context>(context, TRUSTED, AUTH_SRV_KEY)
41
+ // Signature and expiry: an envelope past `dt + ttl` does not verify.
42
+ if (!await envelope.verify(signer.key)) {
43
+ return refuse('signature')
44
+ }
45
+
46
+ const credential = envelope.message<AuthCredentials>()
47
+ if (credential == null || typeof credential !== 'object') {
48
+ return refuse('token')
49
+ }
50
+ if (credential.credential !== signer.user.id) {
51
+ return refuse('issuer')
52
+ }
53
+ if (credential.type !== AuthenticationType.ReCaptcha || credential.role !== AuthRole.Guest
54
+ || credential.userId !== GUEST_ID) {
55
+ return refuse('guest')
56
+ }
57
+ if (typeof credential.challenge !== 'string' || credential.challenge === '') {
58
+ return refuse('challenge')
59
+ }
60
+
61
+ return { credential, challenge: credential.challenge, expiresAt: new Date(envelope.envelope.dt + ttl) }
62
+ }
63
+
64
+ const inspect = async (req: ReCaptchaCarrier): Promise<ReCaptchaGuest | null> => {
65
+ const token = extractAuthToken(req, AuthenticationType.ReCaptcha)
66
+
67
+ return token == null || token === '' ? null : await verify(token)
68
+ }
69
+
70
+ const spend = async (guest: ReCaptchaGuest): Promise<boolean> => {
71
+ try {
72
+ await context.resource<Resource<AuthSpent>>(AUTH_CACHE)
73
+ .create({ id: guest.challenge }, { ttl: AUTHEN_TIMEFRAME / 1000 })
74
+ } catch (error) {
75
+ if (error instanceof RecordExists || (error as { type?: string } | null)?.type === RecordExists.typeName) {
76
+ log.debug('reCAPTCHA token refused', { reason: 'spent' }, { event: 'auth.refused' })
77
+ return false
78
+ }
79
+ throw new AuthUnavailable('auth-cache')
80
+ }
81
+
82
+ return true
83
+ }
84
+
85
+ return { inspect, verify, spend }
86
+ }
87
+
88
+ /** The reCAPTCHA token checks of a context — built once per context. */
89
+ export const reCaptchaTokenOf = memoHelper.oncePer(makeReCaptchaTokenHelper)
@@ -0,0 +1,45 @@
1
+ import type { AuthCredentials } from '@owlmeans/auth'
2
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
3
+
4
+ /** A reCAPTCHA guest token that passed every check but the spend. */
5
+ export interface ReCaptchaGuest {
6
+ /**
7
+ * The credential the auth manager signed: `GUEST_ID`, role Guest, type ReCaptcha, stamped with
8
+ * the auth service record's id. It names nobody.
9
+ */
10
+ credential: AuthCredentials
11
+ /** The server-issued challenge the token carries — the key it is spent under in `AUTH_CACHE`. */
12
+ challenge: string
13
+ /** When the token stops being accepted. */
14
+ expiresAt: Date
15
+ }
16
+
17
+ /** Anything that carries request headers: an entrypoint request, or a raw Fastify request. */
18
+ export interface ReCaptchaCarrier extends Partial<Pick<AbstractRequest, 'headers'>> {}
19
+
20
+ /**
21
+ * The checks of a reCAPTCHA guest token (`Authorization: RE-CAPTCHA <token>`), bound to the context
22
+ * whose `TRUSTED` record `AUTH_SRV_KEY` signed it and whose `AUTH_CACHE` spends it.
23
+ */
24
+ export interface ReCaptchaTokenHelper {
25
+ /**
26
+ * The guest a request carries, checked WITHOUT spending: envelope type, signature by the auth
27
+ * service key, TTL (at most `AUTHEN_TIMEFRAME`), the auth record's stamp, type ReCaptcha, role
28
+ * Guest, `GUEST_ID`, a server-issued challenge. Made for a pre-parse check (before a body is read);
29
+ * the route itself still needs the guard, which spends.
30
+ *
31
+ * @returns `null` for a request without such a header or with a token that fails a check.
32
+ * @throws {SyntaxError} when the context has no `AUTH_SRV_KEY` trusted record (a configuration fault).
33
+ */
34
+ inspect: (req: ReCaptchaCarrier) => Promise<ReCaptchaGuest | null>
35
+ /** {@link inspect} for a bare token (the value after `RE-CAPTCHA `). */
36
+ verify: (token: string) => Promise<ReCaptchaGuest | null>
37
+ /**
38
+ * Spend the token: an atomic create-once of its challenge in `AUTH_CACHE` (Redis `SET NX` in a
39
+ * backend context), under the same key a bearer exchange and the IAM finalizer claim.
40
+ *
41
+ * @returns `false` when the token was spent before.
42
+ * @throws {AuthUnavailable} when the cache cannot answer.
43
+ */
44
+ spend: (guest: ReCaptchaGuest) => Promise<boolean>
45
+ }
package/src/service.ts CHANGED
@@ -1,25 +1,85 @@
1
- import type { ServerContext, ServerConfig } from '@owlmeans/server-context'
1
+ import type { ServerContext } from '@owlmeans/server-context'
2
2
  import { TRUSTED } from '@owlmeans/config'
3
3
  import { AUTH_CACHE, AUTH_SRV_KEY, AUTHEN_TIMEFRAME, DEFAULT_ALIAS } from './consts.js'
4
4
  import type { AuthServiceAppend, AuthService, AuthSpent } from './types.js'
5
5
  import { assertContext, createService } from '@owlmeans/context'
6
6
  import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
7
- import type { Auth, AuthCredentials } from '@owlmeans/auth'
8
- import { AuthenFailed, AuthorizationError, AuthroizationType } from '@owlmeans/auth'
7
+ import { type Auth, type AuthCredentials, AuthenFailed, AuthenticationType, AuthorizationError, AuthroizationType, AuthUnavailable } from '@owlmeans/auth'
9
8
  import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
10
9
  import type { Resource } from '@owlmeans/resource'
11
10
  import { createStaticResource } from '@owlmeans/static-resource'
12
11
  import { trust, extractAuthToken } from '@owlmeans/auth-common/utils'
12
+ import { ENTITY_RESOLVER, type EntityResolverService, TOKEN_UPDATE } from '@owlmeans/auth-common'
13
+ import { AUTH_IDENTITY_PROFILE, type IdentityProfile, type IdentityProfileResource } from '@owlmeans/server-auth-identity'
14
+ import { appendMemoryAuthSessionManager, AUTH_SESSION_MANAGER, AUTH_SESSION_TTL, type AuthSessionManager } from '@owlmeans/server-auth-session'
15
+ import { idHelper } from '@owlmeans/basic-ids'
16
+ import { logger } from '@owlmeans/log'
17
+ import type { Config, Context } from './types.local.js'
13
18
 
14
- type Config = ServerConfig
15
- type Context = ServerContext<Config>
19
+
20
+ const log = logger('server-auth')
16
21
 
17
22
  export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
23
+ /**
24
+ * A refused token exchange — the manager's token presented to open a session. Logged with the
25
+ * reason and the account id only; returns the error for the caller to throw.
26
+ */
27
+ const refuse = (reason: string, error: AuthenFailed, userId?: string): AuthenFailed => {
28
+ const accountId = userId == null ? undefined
29
+ : userId.includes('@') ? `*@${userId.slice(userId.lastIndexOf('@') + 1)}` : userId
30
+ log.warn('Session exchange refused', { reason, ...(accountId != null ? { accountId } : {}) }, { event: 'auth.refused' })
31
+ return error
32
+ }
33
+
18
34
  const location = `server-auth:${alias}`
19
35
 
20
36
  const cache = (context: Context): Resource<AuthSpent> =>
21
37
  context.resource<Resource<AuthSpent>>(AUTH_CACHE)
22
38
 
39
+ const sessions = (context: Context): AuthSessionManager | null =>
40
+ context.hasService(AUTH_SESSION_MANAGER)
41
+ ? context.service<AuthSessionManager>(AUTH_SESSION_MANAGER)
42
+ : null
43
+
44
+ const entityIdFor = async (context: Context, entitySlug: string | undefined): Promise<string | null> => {
45
+ if (entitySlug == null || entitySlug === '') return null
46
+ if (!context.hasService(ENTITY_RESOLVER)) return entitySlug
47
+ const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(entitySlug)
48
+ return entity?.id ?? null
49
+ }
50
+
51
+ const refresh = async (context: Context, auth: Auth, version: number): Promise<Auth | null> => {
52
+ const entityId = await entityIdFor(context, auth.entitySlug)
53
+ if (entityId == null || auth.profileId == null || !context.hasResource(AUTH_IDENTITY_PROFILE)) return null
54
+ const profiles = context.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
55
+ let profile: IdentityProfile | null = null
56
+ try {
57
+ profile = await profiles.load({ entityId, profileId: auth.profileId })
58
+ } catch {
59
+ // A missing profile is a definitive authentication refusal; a failed profile store is an
60
+ // availability dependency and must not look like a revoked browser session.
61
+ throw new AuthUnavailable('identity-profile')
62
+ }
63
+ if (profile == null || (profile.expiresAt != null && new Date(profile.expiresAt).getTime() <= Date.now())) return null
64
+ return {
65
+ ...auth,
66
+ userId: profile.userId ?? auth.userId,
67
+ role: profile.role,
68
+ scopes: profile.scopes ?? [],
69
+ ...(profile.permissions != null ? { permissions: profile.permissions } : {}),
70
+ authorizationVersion: version,
71
+ createdAt: new Date()
72
+ }
73
+ }
74
+
75
+ const sign = async (context: Context, auth: Auth): Promise<string> => {
76
+ const trusted = await trust<Config, Context>(context, TRUSTED, context.cfg.alias ?? context.cfg.service)
77
+ const ttl = auth.expiresAt == null ? AUTH_SESSION_TTL : Math.max(0, new Date(auth.expiresAt).getTime() - Date.now())
78
+ const authorization = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
79
+ .send(auth, ttl).sign(trusted.key, EnvelopeKind.Token)
80
+ return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${authorization}`
81
+ }
82
+
23
83
  const service: AuthService = createService<AuthService>(alias, {
24
84
  match: async req => extractAuthToken(req, AuthroizationType.Ed25519BasicToken) != null,
25
85
 
@@ -37,7 +97,29 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
37
97
  return false as T
38
98
  }
39
99
 
40
- res.resolve(envelope.message())
100
+ let auth = envelope.message()
101
+ const manager = sessions(context)
102
+ if (manager != null) {
103
+ if (auth.sessionId == null) return false as T
104
+ let decision
105
+ try {
106
+ decision = await manager.inspect(auth.sessionId)
107
+ } catch {
108
+ // Registry availability is a security dependency. A guard cannot authenticate through it.
109
+ throw new AuthUnavailable('session-registry')
110
+ }
111
+ if (decision.state !== 'active' && decision.state !== 'refresh') return false as T
112
+ if (decision.state === 'refresh') {
113
+ const refreshed = await refresh(context, auth, decision.version)
114
+ if (refreshed == null) return false as T
115
+ auth = refreshed
116
+ const replacement = await sign(context, auth)
117
+ res.responseProvider?.header(TOKEN_UPDATE, replacement)
118
+ req.headers = { ...req.headers, authorization: replacement }
119
+ }
120
+ }
121
+
122
+ res.resolve(auth)
41
123
 
42
124
  return true as T
43
125
  },
@@ -51,8 +133,19 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
51
133
  if (!await envelope.verify(trusted.key)) {
52
134
  throw new AuthorizationError('unpack')
53
135
  }
54
-
55
- return envelope.message()
136
+ const auth = envelope.message()
137
+ const manager = sessions(context)
138
+ if (manager != null) {
139
+ if (auth.sessionId == null) throw new AuthorizationError('session')
140
+ let decision
141
+ try {
142
+ decision = await manager.inspect(auth.sessionId)
143
+ } catch {
144
+ throw new AuthUnavailable('session-registry')
145
+ }
146
+ if (decision.state !== 'active') throw new AuthorizationError('session')
147
+ }
148
+ return auth
56
149
  },
57
150
 
58
151
  authenticated: async () => {
@@ -65,25 +158,32 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
65
158
 
66
159
  const authService = await trust<Config, Context>(context, TRUSTED, AUTH_SRV_KEY)
67
160
  if (!await envelope.verify(authService.key)) {
68
- throw new AuthenFailed()
161
+ throw refuse('signature', new AuthenFailed())
69
162
  }
70
163
 
71
164
  const credentials = envelope.message()
72
165
  const msg = credentials.challenge
73
166
 
74
- // @TODO This operation is not atomic in case of redis store usage and scling
167
+ // A reCAPTCHA token proves a solved challenge, never a person: it is spent by
168
+ // `makeReCaptchaGuard` on the one route that takes it and never becomes a session. Refused
169
+ // before the spend, so a token presented here stays usable where it belongs.
170
+ if (envelope.type() === AuthenticationType.ReCaptcha || credentials.type === AuthenticationType.ReCaptcha) {
171
+ throw refuse('guest', new AuthenFailed('guest'), credentials.userId)
172
+ }
75
173
 
76
174
  try {
77
175
  await cache(context).create({ id: msg }, { ttl: AUTHEN_TIMEFRAME / 1000 })
78
176
  } catch {
79
- throw new AuthenFailed()
177
+ throw refuse('replay', new AuthenFailed(), credentials.userId)
80
178
  }
81
179
 
82
180
  if (credentials.credential !== authService.user.id) {
83
- throw new AuthenFailed()
181
+ throw refuse('issuer', new AuthenFailed(), credentials.userId)
84
182
  }
85
183
 
86
184
  // @TODO Move to a plugin
185
+ const issuedAt = Date.now()
186
+ const expiresAt = new Date(issuedAt + AUTH_SESSION_TTL)
87
187
  const auth: Auth = {
88
188
  token: credentials.challenge,
89
189
  userId: credentials.userId,
@@ -92,16 +192,26 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
92
192
  type: AuthroizationType.Ed25519BasicToken,
93
193
  source: context.cfg.service,
94
194
  profileId: credentials.profileId,
95
- entityId: credentials.entityId,
195
+ entitySlug: credentials.entitySlug,
96
196
  isUser: true,
97
- createdAt: new Date()
197
+ createdAt: new Date(issuedAt),
198
+ expiresAt,
199
+ sessionId: idHelper.uuid(),
200
+ authorizationVersion: 1
201
+ }
202
+ const manager = sessions(context)
203
+ if (manager != null) {
204
+ const entityId = await entityIdFor(context, auth.entitySlug)
205
+ if (entityId == null || auth.profileId == null) throw refuse('session', new AuthenFailed('session'), auth.userId)
206
+ const decision = await manager.register({
207
+ id: auth.sessionId!, kind: 'bearer', entityId, profileId: auth.profileId,
208
+ issuedAt, expiresAt: expiresAt.getTime()
209
+ })
210
+ if (decision.state !== 'active') throw refuse('session', new AuthenFailed('session'), auth.userId)
211
+ auth.authorizationVersion = decision.version
98
212
  }
99
213
 
100
- const trusted = await trust<Config, Context>(context, TRUSTED, context.cfg.alias ?? context.cfg.service)
101
- const authorization = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
102
- .send(auth, null).sign(trusted.key, EnvelopeKind.Token)
103
-
104
- return { token: `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${authorization}` }
214
+ return { token: await sign(context, auth) }
105
215
  }
106
216
  })
107
217
 
@@ -116,6 +226,10 @@ export const appendAuthService = <C extends Config, T extends ServerContext<C>>(
116
226
  ctx.registerResource(createStaticResource(AUTH_CACHE))
117
227
  }
118
228
 
229
+ // Every standalone server gets an expiring process-local registry. Scaled deployments replace
230
+ // it with appendRedisAuthSessionManager before calling this helper.
231
+ appendMemoryAuthSessionManager(ctx)
232
+
119
233
  const service = makeAuthService(alias)
120
234
  const context = ctx as T & AuthServiceAppend
121
235
 
@@ -0,0 +1,5 @@
1
+ import type { ServerContext, ServerConfig } from '@owlmeans/server-context'
2
+
3
+ export type Config = ServerConfig
4
+
5
+ export type Context = ServerContext<Config>
package/tests/context.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AppType, Layer, makeBasicContext } from '@owlmeans/context'
1
+ import { AppType, makeBasicContext } from '@owlmeans/context'
2
2
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
3
  import { createStaticResource } from '@owlmeans/static-resource'
4
4
  import { makeMemoryTrustedResource, makeFixtureKeyPair } from '@owlmeans/test-auth'
@@ -25,6 +25,8 @@ export const makeTestContext = () => {
25
25
  id: authServiceKP.exportAddress(),
26
26
  name: AUTH_SRV_KEY,
27
27
  credential: authServiceKP.exportPublic(),
28
+ // Local, deterministic test-only key: the manager signs its synthetic exchange envelope.
29
+ secret: authServiceKP.export(),
28
30
  scopes: ['*'],
29
31
  }
30
32
 
@@ -39,7 +41,6 @@ export const makeTestContext = () => {
39
41
  const cfg: BasicConfig = {
40
42
  ready: false,
41
43
  service: SERVICE_NAME,
42
- layer: Layer.Service,
43
44
  type: AppType.Backend,
44
45
  services: {},
45
46
  }