@owlmeans/server-auth 0.1.18-rc.4 → 0.1.18-rc.40

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 (111) hide show
  1. package/README.md +184 -38
  2. package/agent-meta/manifest.json +9 -2
  3. package/agent-meta/skills/server-auth/SKILL.md +101 -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/entrypoints.d.ts +5 -0
  10. package/build/entrypoints.d.ts.map +1 -0
  11. package/build/entrypoints.js +9 -0
  12. package/build/entrypoints.js.map +1 -0
  13. package/build/index.d.ts +1 -1
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js +1 -1
  16. package/build/index.js.map +1 -1
  17. package/build/manager/actions/manager.d.ts +17 -5
  18. package/build/manager/actions/manager.d.ts.map +1 -1
  19. package/build/manager/actions/manager.js +7 -6
  20. package/build/manager/actions/manager.js.map +1 -1
  21. package/build/manager/context.d.ts.map +1 -1
  22. package/build/manager/context.js +0 -1
  23. package/build/manager/context.js.map +1 -1
  24. package/build/manager/entrypoints.d.ts +9 -0
  25. package/build/manager/entrypoints.d.ts.map +1 -0
  26. package/build/manager/entrypoints.js +24 -0
  27. package/build/manager/entrypoints.js.map +1 -0
  28. package/build/manager/exports.d.ts +1 -2
  29. package/build/manager/exports.d.ts.map +1 -1
  30. package/build/manager/exports.js +1 -2
  31. package/build/manager/exports.js.map +1 -1
  32. package/build/manager/index.d.ts +1 -1
  33. package/build/manager/index.d.ts.map +1 -1
  34. package/build/manager/index.js +1 -1
  35. package/build/manager/index.js.map +1 -1
  36. package/build/manager/main.js +2 -2
  37. package/build/manager/main.js.map +1 -1
  38. package/build/manager/model.d.ts.map +1 -1
  39. package/build/manager/model.js +26 -10
  40. package/build/manager/model.js.map +1 -1
  41. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  42. package/build/manager/plugins/basic-rely.d.ts.map +1 -1
  43. package/build/manager/plugins/basic-rely.js +5 -5
  44. package/build/manager/plugins/basic-rely.js.map +1 -1
  45. package/build/manager/plugins/export.d.ts +1 -0
  46. package/build/manager/plugins/export.d.ts.map +1 -1
  47. package/build/manager/plugins/export.js +1 -0
  48. package/build/manager/plugins/export.js.map +1 -1
  49. package/build/manager/plugins/re-captcha.js +1 -1
  50. package/build/manager/plugins/re-captcha.js.map +1 -1
  51. package/build/manager/plugins/replay-policy.d.ts +8 -0
  52. package/build/manager/plugins/replay-policy.d.ts.map +1 -0
  53. package/build/manager/plugins/replay-policy.js +9 -0
  54. package/build/manager/plugins/replay-policy.js.map +1 -0
  55. package/build/manager/plugins/supervisor.js +3 -3
  56. package/build/manager/plugins/supervisor.js.map +1 -1
  57. package/build/manager/plugins/types.d.ts +6 -0
  58. package/build/manager/plugins/types.d.ts.map +1 -1
  59. package/build/manager/rely/flow.js +1 -1
  60. package/build/manager/rely/flow.js.map +1 -1
  61. package/build/manager/supervisor.d.ts +5 -5
  62. package/build/manager/supervisor.d.ts.map +1 -1
  63. package/build/manager/supervisor.js +4 -4
  64. package/build/manager/supervisor.js.map +1 -1
  65. package/build/model/rely/consumer.d.ts +3 -1
  66. package/build/model/rely/consumer.d.ts.map +1 -1
  67. package/build/model/rely/consumer.js.map +1 -1
  68. package/build/model/rely/provider.d.ts +3 -1
  69. package/build/model/rely/provider.d.ts.map +1 -1
  70. package/build/model/rely/provider.js.map +1 -1
  71. package/build/service.d.ts.map +1 -1
  72. package/build/service.js +116 -9
  73. package/build/service.js.map +1 -1
  74. package/package.json +28 -26
  75. package/src/actions/service.ts +9 -8
  76. package/src/entrypoints.ts +12 -0
  77. package/src/index.ts +1 -1
  78. package/src/manager/README.md +14 -18
  79. package/src/manager/actions/manager.ts +15 -13
  80. package/src/manager/context.ts +0 -2
  81. package/src/manager/entrypoints.ts +26 -0
  82. package/src/manager/exports.ts +1 -2
  83. package/src/manager/index.ts +1 -1
  84. package/src/manager/main.ts +2 -2
  85. package/src/manager/model.ts +28 -10
  86. package/src/manager/plugins/basic-ed25519.ts +2 -2
  87. package/src/manager/plugins/basic-rely.ts +7 -6
  88. package/src/manager/plugins/export.ts +1 -0
  89. package/src/manager/plugins/re-captcha.ts +3 -3
  90. package/src/manager/plugins/replay-policy.ts +7 -0
  91. package/src/manager/plugins/supervisor.ts +4 -4
  92. package/src/manager/plugins/types.ts +6 -0
  93. package/src/manager/rely/flow.ts +1 -1
  94. package/src/manager/supervisor.ts +6 -6
  95. package/src/model/rely/consumer.ts +3 -1
  96. package/src/model/rely/provider.ts +3 -1
  97. package/src/service.ts +114 -11
  98. package/tests/context.ts +3 -2
  99. package/tests/replay-policy.spec.ts +36 -0
  100. package/tests/supervisor.spec.ts +6 -4
  101. package/build/.gitkeep +0 -0
  102. package/build/manager/modules.d.ts +0 -2
  103. package/build/manager/modules.d.ts.map +0 -1
  104. package/build/manager/modules.js +0 -21
  105. package/build/manager/modules.js.map +0 -1
  106. package/build/modules.d.ts +0 -3
  107. package/build/modules.d.ts.map +0 -1
  108. package/build/modules.js +0 -11
  109. package/build/modules.js.map +0 -1
  110. package/src/manager/modules.ts +0 -29
  111. package/src/modules.ts +0 -17
@@ -1,9 +1,9 @@
1
1
 
2
- import { modules } from './modules.js'
2
+ import { entrypoints } from './entrypoints.js'
3
3
  import type { AppContext, AppConfig } from './types.js'
4
4
 
5
5
  export const main = async <C extends AppConfig, T extends AppContext<C>>(ctx: T) => {
6
- ctx.registerEntrypoints(modules)
6
+ ctx.registerEntrypoints(entrypoints)
7
7
  await ctx.configure().init()
8
8
  await ctx.getApiServer().listen()
9
9
  }
@@ -1,6 +1,8 @@
1
1
  import type { AuthModel, AppConfig, AppContext } from './types.js'
2
- import { AuthenFailed, AuthenPayloadError } from '@owlmeans/auth'
2
+ import { AuthenFailed, AuthenPayloadError, entitySlugOf } from '@owlmeans/auth'
3
3
  import type { AuthCredentials } from '@owlmeans/auth'
4
+ import type { EntityResolverService } from '@owlmeans/auth-common'
5
+ import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
4
6
  import { getPlugin } from './plugins/utils.js'
5
7
  import { AUTH_CACHE, AUTHEN_TIMEFRAME } from '../consts.js'
6
8
  import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
@@ -10,6 +12,7 @@ import { trusted } from './utils/trusted.js'
10
12
  import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
11
13
  import type { Resource } from '@owlmeans/resource'
12
14
  import type { AuthSpent } from '../types.js'
15
+ import { AuthChallengeReplayPolicy } from './plugins/replay-policy.js'
13
16
 
14
17
  type Config = ServerConfig
15
18
  type Context = ServerContext<Config>
@@ -49,22 +52,24 @@ export const makeAuthModel = (context: AppContext<AppConfig>): AuthModel => {
49
52
 
50
53
  const msg: string = envelope.message(true)
51
54
 
52
- 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 plugin = await getPlugin(envelope.type(), context)
56
+
57
+ if (plugin.challengeReplayPolicy !== AuthChallengeReplayPolicy.Plugin) {
58
+ try {
59
+ await cache(context).create({ id: msg }, { ttl: (envelope.envelope.ttl ?? AUTHEN_TIMEFRAME) / 1000 })
60
+ } catch (e) {
61
+ const error = new AuthenFailed('challenge')
62
+ if (e instanceof Error) {
63
+ error.oiriginalStack = `${e} : ${e.stack}`
64
+ }
65
+ throw error
58
66
  }
59
- throw error
60
67
  }
61
68
 
62
69
  if (credential.userId == null) {
63
70
  throw new AuthenPayloadError('userId')
64
71
  }
65
72
 
66
- const plugin = await getPlugin(envelope.type(), context)
67
-
68
73
  // @TODO this token PROBABLY needs to be registered somewhere and rechecked
69
74
  // from time to time to make sure that the session is not deleted
70
75
  // or permission hasn't changed.
@@ -75,6 +80,19 @@ export const makeAuthModel = (context: AppContext<AppConfig>): AuthModel => {
75
80
  const { token } = await plugin.authenticate(Object.assign(credential, { challenge: msg }))
76
81
  credential.challenge = token === '' ? challenge : token
77
82
 
83
+ // Plugins accept whatever the caller typed — a current slug, a slug the organization has
84
+ // since retired, or (for a token minted before slugs existed) its stable key. The token is
85
+ // signed once and read for as long as it lives, so the value is canonicalized here rather
86
+ // than at every place that later reads it back.
87
+ const entitySlug = entitySlugOf(credential)
88
+ if (entitySlug != null && context.hasService(ENTITY_RESOLVER)) {
89
+ const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(entitySlug)
90
+ if (entity == null) {
91
+ throw new AuthenFailed('entity')
92
+ }
93
+ credential.entitySlug = entity.slug
94
+ }
95
+
78
96
  credential.credential = trustedUser.id
79
97
 
80
98
  return {
@@ -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()
@@ -7,17 +7,18 @@ 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'
11
12
 
12
- const _subscriptions: Record<string, CallableFunction> = {}
13
+ const _subscriptions: Record<string, Unsubscribe> = {}
13
14
  export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
14
15
  const plugin: AuthPlugin = {
15
16
  type: type ?? AuthenticationType.RelyHandshake,
16
17
 
17
18
  // This method is used by consumer and by provider (wallet)
18
19
  // 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.
20
+ // The idea is that both watch the record holding their own pin/token and wake
21
+ // up when the peer writes its handshake value into it.
21
22
  init: async (request: RelyAllowanceRequest): Promise<AllowanceResponse> => {
22
23
  const [, keyPair] = await trusted(context)
23
24
  const tunnel = context.resource<AuthRedisResource>(AUTH_CACHE)
@@ -41,8 +42,8 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
41
42
 
42
43
  if (request.provideRely != null) {
43
44
  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) {
45
+ _subscriptions[key] = await tunnel.watch(key, async value => {
46
+ if (value?.challenge != null) {
46
47
  const rely = makeEnvelopeModel<RelyToken>(value.challenge, EnvelopeKind.Wrap)
47
48
  if (await rely.verify(keyPair)) {
48
49
  [pin, token].filter(key => key != null).forEach(key => {
@@ -54,7 +55,7 @@ export const basicRely = (context: AppContext, type?: string): AuthPlugin => {
54
55
  await request.provideRely?.(rely.message(), relyMsg, true)
55
56
  }
56
57
  }
57
- }, { /*ttl: RELY_CALL_TIMEOUT,*/ key, once: true })
58
+ }, { once: true })
58
59
 
59
60
  // @TODO they need to be killed on close - but actually live a little bit longer
60
61
  // then timeout for them is ok
@@ -1,5 +1,6 @@
1
1
 
2
2
  export type * from './types.js'
3
+ export * from './replay-policy.js'
3
4
  export * from './basic-ed25519.js'
4
5
  export * from './re-captcha.js'
5
6
  export * from './basic-rely.js'
@@ -23,13 +23,13 @@ export const reCaptcha = <C extends AppConfig, T extends AppContext<C>>(context:
23
23
  },
24
24
 
25
25
  authenticate: async credential => {
26
- const cfg = context.getConfigResource(PLUGINS)
27
- const config = await cfg.get<PluginConfig>(MOD_RECAPTCHA)
26
+ const cfg = context.getConfigResource<PluginConfig>(PLUGINS)
27
+ const config = await cfg.get(MOD_RECAPTCHA)
28
28
  if (config.value == null) {
29
29
  throw new PluginMissconfigured('value')
30
30
  }
31
31
  const validateRecaptcha = context.entrypoint<ClientEntrypoint<RecpatchaResponse>>(MOD_RECAPTCHA)
32
- const [result, status] = await validateRecaptcha.call<RecpatchaResponse, RecaptchaRequest>({
32
+ const { value: result, outcome: status } = await validateRecaptcha.invoke<RecpatchaResponse, RecaptchaRequest>({
33
33
  body: {
34
34
  secret: config.value,
35
35
  response: credential.credential
@@ -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
+ }
@@ -48,8 +48,8 @@ export const makeSupervisorPlugin = (
48
48
  let matched: string | null = null
49
49
  for (const alias of opts.supervisors) {
50
50
  try {
51
- const record = await context.getConfigResource(TRUSTED)
52
- .load<TrustedRecord>(alias, 'name')
51
+ const record = await context.getConfigResource<TrustedRecord>(TRUSTED)
52
+ .load({ name: alias })
53
53
  if (record?.credential == null) {
54
54
  continue
55
55
  }
@@ -71,8 +71,8 @@ export const makeSupervisorPlugin = (
71
71
 
72
72
  credential.userId = resolution.userId
73
73
  credential.profileId = resolution.profileId ?? resolution.userId
74
- if (resolution.entityId != null) {
75
- credential.entityId = resolution.entityId
74
+ if (resolution.entitySlug != null) {
75
+ credential.entitySlug = resolution.entitySlug
76
76
  }
77
77
  credential.scopes = resolution.scopes ?? credential.scopes ?? [ALL_SCOPES]
78
78
  credential.role = resolution.role ?? AuthRole.User
@@ -3,9 +3,15 @@ import type { AuthModel } from '../types.js'
3
3
  import type { AbstractRequest } from '@owlmeans/entrypoint'
4
4
  import type { RedisResource } from '@owlmeans/redis-resource'
5
5
  import type { ResourceRecord } from '@owlmeans/resource'
6
+ import type { AuthChallengeReplayPolicy } from './replay-policy.js'
6
7
 
7
8
  export interface AuthPlugin extends Omit<AuthModel, "rely"> {
8
9
  type: string
10
+ /**
11
+ * Who atomically consumes a verified challenge. The manager remains the default; a plugin may
12
+ * opt in only when its own credential protocol needs a bounded retry budget before consumption.
13
+ */
14
+ challengeReplayPolicy?: AuthChallengeReplayPolicy
9
15
  }
10
16
 
11
17
  export interface RecpatchaResponse {
@@ -38,7 +38,7 @@ export const createRelyFlow = (context: AppContext, conn: Connection, auth?: Aut
38
38
  await closeReceiver()
39
39
  }
40
40
  }
41
- }, source.nonce)
41
+ }, { channel: source.nonce })
42
42
  // We allow to just forward call messages back and forth
43
43
  conn._receiveCall = async msg => { console.info(source.nonce, 'Forward call', msg.method, msg.id) }
44
44
  conn._receiveResult = async msg => { console.info(source.nonce, 'Forward result', msg.id) }
@@ -10,7 +10,7 @@ import { makeSupervisorPlugin } from './plugins/supervisor.js'
10
10
  * token will represent. Only `userId` is required - the rest default sensibly.
11
11
  */
12
12
  export interface SupervisorUserResolution extends Partial<Pick<AuthPayload,
13
- 'profileId' | 'entityId' | 'role' | 'scopes'>> {
13
+ 'profileId' | 'entitySlug' | 'role' | 'scopes'>> {
14
14
  userId: string
15
15
  }
16
16
 
@@ -43,7 +43,7 @@ export interface SupervisorAuthOptions {
43
43
  enabled?: boolean
44
44
  /**
45
45
  * Also accept internal owlmeans `Ed25519BasicToken`s even when another guard
46
- * (e.g. OIDC) is the primary guard on protected modules. Default: true.
46
+ * (e.g. OIDC) is the primary guard on protected entrypoints. Default: true.
47
47
  */
48
48
  acceptInternalTokens?: boolean
49
49
  /** The internal-token guard to add as a coguard. Default: DEFAULT_GUARD ('auth'). */
@@ -57,15 +57,15 @@ const isDevelopment = (context: { cfg: { debug?: { all?: boolean, supervisor?: b
57
57
 
58
58
  /**
59
59
  * Ensure the internal `Ed25519BasicToken` guard is accepted as a coguard on every
60
- * already-guarded backend module, so internal owlmeans tokens keep working even
60
+ * already-guarded backend entrypoint, so internal owlmeans tokens keep working even
61
61
  * when another guard (e.g. OIDC) is the primary guard. The primary guard stays
62
62
  * first; the internal guard is appended as a fallback (its `match` only fires for
63
63
  * an `Ed25519BasicToken` authorization header).
64
64
  */
65
65
  export const setupInternalTokenCoguard = (
66
- modules: Array<{ guards?: string[] }>, guard: string = DEFAULT_GUARD
66
+ entrypoints: Array<{ guards?: string[] }>, guard: string = DEFAULT_GUARD
67
67
  ): void => {
68
- modules.forEach(module => {
68
+ entrypoints.forEach(module => {
69
69
  if (module.guards != null && module.guards.length > 0 && !module.guards.includes(guard)) {
70
70
  module.guards.push(guard)
71
71
  }
@@ -78,7 +78,7 @@ export const setupInternalTokenCoguard = (
78
78
  *
79
79
  * It registers the supervisor auth plugin (verifies a front-end signature against
80
80
  * the allowlisted trusted keys, then resolves/registers the target user) and -
81
- * unless disabled - makes protected modules also accept internal owlmeans tokens
81
+ * unless disabled - makes protected entrypoints also accept internal owlmeans tokens
82
82
  * (requirement: understand internal tokens even when OIDC is the primary guard).
83
83
  */
84
84
  export const appendSupervisorAuth = <C extends AppConfig, T extends AppContext<C>>(
@@ -1,7 +1,9 @@
1
1
  import { makeRelyModel } from './peer.js'
2
+ import type { RelyToken } from '@owlmeans/auth'
3
+ import type { EnvelopeModel } from '@owlmeans/basic-envelope'
2
4
  import type { RelyOptions } from './types.js'
3
5
 
4
- export const makeConsumerRely = (opts?: RelyOptions) => {
6
+ export const makeConsumerRely = (opts?: RelyOptions): EnvelopeModel<RelyToken> => {
5
7
  const model = makeRelyModel(opts)
6
8
  const msg = model.message()
7
9
  delete msg.pin
@@ -1,8 +1,10 @@
1
1
 
2
2
  import { makeRelyModel } from './peer.js'
3
+ import type { RelyToken } from '@owlmeans/auth'
4
+ import type { EnvelopeModel } from '@owlmeans/basic-envelope'
3
5
  import type { RelyOptions } from './types.js'
4
6
 
5
- export const makeProviderRely = (opts?: RelyOptions) => {
7
+ export const makeProviderRely = (opts?: RelyOptions): EnvelopeModel<RelyToken> => {
6
8
  const model = makeRelyModel(opts)
7
9
 
8
10
  return model
package/src/service.ts CHANGED
@@ -5,11 +5,21 @@ 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
7
  import type { Auth, AuthCredentials } from '@owlmeans/auth'
8
- import { AuthenFailed, AuthorizationError, AuthroizationType } from '@owlmeans/auth'
8
+ import { AuthenFailed, AuthorizationError, AuthroizationType, AuthUnavailable } from '@owlmeans/auth'
9
9
  import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
10
10
  import type { Resource } from '@owlmeans/resource'
11
11
  import { createStaticResource } from '@owlmeans/static-resource'
12
12
  import { trust, extractAuthToken } from '@owlmeans/auth-common/utils'
13
+ import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
14
+ import type { EntityResolverService } from '@owlmeans/auth-common'
15
+ import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
16
+ import type { IdentityProfile, IdentityProfileResource } from '@owlmeans/server-auth-identity'
17
+ import {
18
+ appendMemoryAuthSessionManager, AUTH_SESSION_MANAGER, AUTH_SESSION_TTL
19
+ } from '@owlmeans/server-auth-session'
20
+ import type { AuthSessionManager } from '@owlmeans/server-auth-session'
21
+ import { uuid } from '@owlmeans/basic-ids'
22
+ import { TOKEN_UPDATE } from '@owlmeans/auth-common'
13
23
 
14
24
  type Config = ServerConfig
15
25
  type Context = ServerContext<Config>
@@ -20,6 +30,50 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
20
30
  const cache = (context: Context): Resource<AuthSpent> =>
21
31
  context.resource<Resource<AuthSpent>>(AUTH_CACHE)
22
32
 
33
+ const sessions = (context: Context): AuthSessionManager | null =>
34
+ context.hasService(AUTH_SESSION_MANAGER)
35
+ ? context.service<AuthSessionManager>(AUTH_SESSION_MANAGER)
36
+ : null
37
+
38
+ const entityIdFor = async (context: Context, entitySlug: string | undefined): Promise<string | null> => {
39
+ if (entitySlug == null || entitySlug === '') return null
40
+ if (!context.hasService(ENTITY_RESOLVER)) return entitySlug
41
+ const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(entitySlug)
42
+ return entity?.id ?? null
43
+ }
44
+
45
+ const refresh = async (context: Context, auth: Auth, version: number): Promise<Auth | null> => {
46
+ const entityId = await entityIdFor(context, auth.entitySlug)
47
+ if (entityId == null || auth.profileId == null || !context.hasResource(AUTH_IDENTITY_PROFILE)) return null
48
+ const profiles = context.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
49
+ let profile: IdentityProfile | null = null
50
+ try {
51
+ profile = await profiles.load({ entityId, profileId: auth.profileId })
52
+ } catch {
53
+ // A missing profile is a definitive authentication refusal; a failed profile store is an
54
+ // availability dependency and must not look like a revoked browser session.
55
+ throw new AuthUnavailable('identity-profile')
56
+ }
57
+ if (profile == null || (profile.expiresAt != null && new Date(profile.expiresAt).getTime() <= Date.now())) return null
58
+ return {
59
+ ...auth,
60
+ userId: profile.userId ?? auth.userId,
61
+ role: profile.role,
62
+ scopes: profile.scopes ?? [],
63
+ ...(profile.permissions != null ? { permissions: profile.permissions } : {}),
64
+ authorizationVersion: version,
65
+ createdAt: new Date()
66
+ }
67
+ }
68
+
69
+ const sign = async (context: Context, auth: Auth): Promise<string> => {
70
+ const trusted = await trust<Config, Context>(context, TRUSTED, context.cfg.alias ?? context.cfg.service)
71
+ const ttl = auth.expiresAt == null ? AUTH_SESSION_TTL : Math.max(0, new Date(auth.expiresAt).getTime() - Date.now())
72
+ const authorization = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
73
+ .send(auth, ttl).sign(trusted.key, EnvelopeKind.Token)
74
+ return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${authorization}`
75
+ }
76
+
23
77
  const service: AuthService = createService<AuthService>(alias, {
24
78
  match: async req => extractAuthToken(req, AuthroizationType.Ed25519BasicToken) != null,
25
79
 
@@ -37,7 +91,29 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
37
91
  return false as T
38
92
  }
39
93
 
40
- res.resolve(envelope.message())
94
+ let auth = envelope.message()
95
+ const manager = sessions(context)
96
+ if (manager != null) {
97
+ if (auth.sessionId == null) return false as T
98
+ let decision
99
+ try {
100
+ decision = await manager.inspect(auth.sessionId)
101
+ } catch {
102
+ // Registry availability is a security dependency. A guard cannot authenticate through it.
103
+ throw new AuthUnavailable('session-registry')
104
+ }
105
+ if (decision.state !== 'active' && decision.state !== 'refresh') return false as T
106
+ if (decision.state === 'refresh') {
107
+ const refreshed = await refresh(context, auth, decision.version)
108
+ if (refreshed == null) return false as T
109
+ auth = refreshed
110
+ const replacement = await sign(context, auth)
111
+ res.responseProvider?.header(TOKEN_UPDATE, replacement)
112
+ req.headers = { ...req.headers, authorization: replacement }
113
+ }
114
+ }
115
+
116
+ res.resolve(auth)
41
117
 
42
118
  return true as T
43
119
  },
@@ -51,8 +127,19 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
51
127
  if (!await envelope.verify(trusted.key)) {
52
128
  throw new AuthorizationError('unpack')
53
129
  }
54
-
55
- return envelope.message()
130
+ const auth = envelope.message()
131
+ const manager = sessions(context)
132
+ if (manager != null) {
133
+ if (auth.sessionId == null) throw new AuthorizationError('session')
134
+ let decision
135
+ try {
136
+ decision = await manager.inspect(auth.sessionId)
137
+ } catch {
138
+ throw new AuthUnavailable('session-registry')
139
+ }
140
+ if (decision.state !== 'active') throw new AuthorizationError('session')
141
+ }
142
+ return auth
56
143
  },
57
144
 
58
145
  authenticated: async () => {
@@ -84,6 +171,8 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
84
171
  }
85
172
 
86
173
  // @TODO Move to a plugin
174
+ const issuedAt = Date.now()
175
+ const expiresAt = new Date(issuedAt + AUTH_SESSION_TTL)
87
176
  const auth: Auth = {
88
177
  token: credentials.challenge,
89
178
  userId: credentials.userId,
@@ -92,16 +181,26 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
92
181
  type: AuthroizationType.Ed25519BasicToken,
93
182
  source: context.cfg.service,
94
183
  profileId: credentials.profileId,
95
- entityId: credentials.entityId,
184
+ entitySlug: credentials.entitySlug,
96
185
  isUser: true,
97
- createdAt: new Date()
186
+ createdAt: new Date(issuedAt),
187
+ expiresAt,
188
+ sessionId: uuid(),
189
+ authorizationVersion: 1
190
+ }
191
+ const manager = sessions(context)
192
+ if (manager != null) {
193
+ const entityId = await entityIdFor(context, auth.entitySlug)
194
+ if (entityId == null || auth.profileId == null) throw new AuthenFailed('session')
195
+ const decision = await manager.register({
196
+ id: auth.sessionId!, kind: 'bearer', entityId, profileId: auth.profileId,
197
+ issuedAt, expiresAt: expiresAt.getTime()
198
+ })
199
+ if (decision.state !== 'active') throw new AuthenFailed('session')
200
+ auth.authorizationVersion = decision.version
98
201
  }
99
202
 
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}` }
203
+ return { token: await sign(context, auth) }
105
204
  }
106
205
  })
107
206
 
@@ -116,6 +215,10 @@ export const appendAuthService = <C extends Config, T extends ServerContext<C>>(
116
215
  ctx.registerResource(createStaticResource(AUTH_CACHE))
117
216
  }
118
217
 
218
+ // Every standalone server gets an expiring process-local registry. Scaled deployments replace
219
+ // it with appendRedisAuthSessionManager before calling this helper.
220
+ appendMemoryAuthSessionManager(ctx)
221
+
119
222
  const service = makeAuthService(alias)
120
223
  const context = ctx as T & AuthServiceAppend
121
224
 
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
  }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthenFailed, AuthRole } from '@owlmeans/auth'
3
+ import { makeAuthModel } from '../src/manager/model.js'
4
+ import { AuthChallengeReplayPolicy } from '../src/manager/plugins/replay-policy.js'
5
+ import { registerPlugin } from '../src/manager/plugins/index.js'
6
+ import { makeTestContext } from './context.js'
7
+
8
+ describe('@owlmeans/server-auth — plugin-owned challenge replay', () => {
9
+ test('lets a bounded-retry plugin receive a second credential attempt', async () => {
10
+ const type = `replay-policy-${Date.now()}`
11
+ let attempts = 0
12
+ registerPlugin(type, () => ({
13
+ type,
14
+ challengeReplayPolicy: AuthChallengeReplayPolicy.Plugin,
15
+ init: async () => ({ challenge: 'opaque-issuance' }),
16
+ authenticate: async () => {
17
+ attempts += 1
18
+ if (attempts === 1) throw new AuthenFailed('first-guess')
19
+ return { token: '' }
20
+ },
21
+ }))
22
+
23
+ const { context } = makeTestContext()
24
+ await context.configure().init()
25
+ const model = makeAuthModel(context as never)
26
+ const { challenge } = await model.init({ type, userId: 'retry@example.com' })
27
+ const credential = () => ({
28
+ type, challenge, credential: 'guess', userId: 'retry@example.com',
29
+ role: AuthRole.User, scopes: ['*'],
30
+ })
31
+
32
+ await expect(model.authenticate(credential())).rejects.toBeInstanceOf(AuthenFailed)
33
+ await expect(model.authenticate(credential())).resolves.toHaveProperty('token')
34
+ expect(attempts).toBe(2)
35
+ })
36
+ })
@@ -3,6 +3,8 @@ import { makeFixtureKeyPair } from '@owlmeans/test-auth'
3
3
  import { AuthenticationType, AuthRole, buildSupervisorPayload } from '@owlmeans/auth'
4
4
  import type { AuthCredentials } from '@owlmeans/auth'
5
5
  import type { TrustedRecord } from '@owlmeans/auth-common'
6
+ import type { Criteria } from '@owlmeans/resource'
7
+ import { firstMatch } from '@owlmeans/resource'
6
8
  import { makeSupervisorPlugin } from '../src/manager/plugins/supervisor.js'
7
9
  import type { AppConfig, AppContext } from '../src/manager/types.js'
8
10
 
@@ -10,8 +12,8 @@ const SUPERVISOR = 'master'
10
12
 
11
13
  const makeStubContext = (records: TrustedRecord[]): AppContext<AppConfig> => ({
12
14
  getConfigResource: () => ({
13
- load: async (id: string, field?: string) =>
14
- records.find(r => (field === 'name' ? r.name === id : r.id === id)) ?? null
15
+ load: async (idOrWhere: string | Criteria<TrustedRecord>) =>
16
+ firstMatch(records, typeof idOrWhere === 'string' ? { id: idOrWhere } : idOrWhere)
15
17
  })
16
18
  } as unknown as AppContext<AppConfig>)
17
19
 
@@ -61,7 +63,7 @@ describe('@owlmeans/server-auth — supervisor plugin', () => {
61
63
  const plugin = makeSupervisorPlugin(context, {
62
64
  supervisors: [SUPERVISOR],
63
65
  allowRegistration: true,
64
- resolveUser: async (userId) => ({ userId: 'internal-id', profileId: 'profile-1', entityId: 'entity-1', scopes: ['project'] })
66
+ resolveUser: async (userId) => ({ userId: 'internal-id', profileId: 'profile-1', entitySlug: 'entity-1', scopes: ['project'] })
65
67
  })
66
68
 
67
69
  const challenge = 'challenge-' + Date.now()
@@ -72,7 +74,7 @@ describe('@owlmeans/server-auth — supervisor plugin', () => {
72
74
 
73
75
  expect(credential.userId).toBe('internal-id')
74
76
  expect(credential.profileId).toBe('profile-1')
75
- expect(credential.entityId).toBe('entity-1')
77
+ expect(credential.entitySlug).toBe('entity-1')
76
78
  expect(credential.scopes).toEqual(['project'])
77
79
  })
78
80
 
package/build/.gitkeep DELETED
File without changes
@@ -1,2 +0,0 @@
1
- export declare const modules: import("@owlmeans/entrypoint").CommonEntrypoint[];
2
- //# sourceMappingURL=modules.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.d.ts","sourceRoot":"","sources":["../../src/manager/modules.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,OAAO,mDAAO,CAAA"}
@@ -1,21 +0,0 @@
1
- import { AUTHEN, AUTHEN_AUTHEN, AUTHEN_INIT, AUTHEN_RELY, MOD_RECAPTCHA } from '@owlmeans/auth';
2
- import { modules as list } from '@owlmeans/auth-common';
3
- import { modules as apiConfig } from '@owlmeans/api-config-server';
4
- import { elevate, guard } from '@owlmeans/server-entrypoint';
5
- import * as actions from './actions/index.js';
6
- import { entrypoint } from '@owlmeans/client-entrypoint';
7
- import { backend, route, RouteMethod } from '@owlmeans/route';
8
- import { handleIntermediate } from '@owlmeans/server-api';
9
- import { DEFAULT_RELY } from './consts.js';
10
- elevate(list, AUTHEN, handleIntermediate(async (_, context) => await context.updateContext()), { intermediate: true });
11
- elevate(list, AUTHEN_INIT, actions.authenticationInit);
12
- elevate(list, AUTHEN_AUTHEN, actions.authenticate);
13
- elevate(list, AUTHEN_RELY, actions.rely, guard(DEFAULT_RELY));
14
- export const modules = list;
15
- list.push(...apiConfig);
16
- list.push(entrypoint(route(MOD_RECAPTCHA, '/api/siteverify', backend({
17
- host: 'https://www.google.com',
18
- base: 'recaptcha',
19
- secure: true,
20
- }, RouteMethod.POST))));
21
- //# sourceMappingURL=modules.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.js","sourceRoot":"","sources":["../../src/manager/modules.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC/F,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,6BAA6B,CAAA;AAClE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAA;AAC5D,OAAO,KAAK,OAAO,MAAM,oBAAoB,CAAA;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAA;AACxD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE1C,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,kBAAkB,CACtC,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,CAAC,MAAM,OAAO,CAAC,aAAa,EAAoB,CACtE,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,CAAC,CAAA;AAC1B,OAAO,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,kBAAkB,CAAC,CAAA;AACtD,OAAO,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,CAAC,YAAY,CAAC,CAAA;AAClD,OAAO,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,YAAY,CAAC,CAAC,CAAA;AAE7D,MAAM,CAAC,MAAM,OAAO,GAAG,IAAI,CAAA;AAC3B,IAAI,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAA;AACvB,IAAI,CAAC,IAAI,CACP,UAAU,CACR,KAAK,CAAC,aAAa,EAAE,iBAAiB,EAAE,OAAO,CAAC;IAC9C,IAAI,EAAE,wBAAwB;IAC9B,IAAI,EAAE,WAAW;IACjB,MAAM,EAAE,IAAI;CACb,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC,CACtB,CACF,CAAA"}
@@ -1,3 +0,0 @@
1
- import type { ServerEntrypoint } from '@owlmeans/server-entrypoint';
2
- export declare const modules: ServerEntrypoint<unknown>[];
3
- //# sourceMappingURL=modules.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.d.ts","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAOnE,eAAO,MAAM,OAAO,EAAE,gBAAgB,CAAC,OAAO,CAAC,EAK9C,CAAA"}
package/build/modules.js DELETED
@@ -1,11 +0,0 @@
1
- import { elevate } from '@owlmeans/server-entrypoint';
2
- import { modules as list } from '@owlmeans/auth-common';
3
- import { DISPATCHER, DISPATCHER_AUTHEN } from '@owlmeans/auth';
4
- import { authenticate } from './actions/service.js';
5
- import { stab, elevate as celevate } from '@owlmeans/client-entrypoint';
6
- elevate(list, DISPATCHER_AUTHEN, authenticate);
7
- celevate(list, DISPATCHER, stab);
8
- export const modules = [
9
- ...list.filter((module) => [DISPATCHER, DISPATCHER_AUTHEN].includes(module.getAlias()))
10
- ];
11
- //# sourceMappingURL=modules.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"modules.js","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAA;AACrD,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAE9D,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AACnD,OAAO,EAAE,IAAI,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AAEvE,OAAO,CAAC,IAAI,EAAE,iBAAiB,EAAE,YAAY,CAAC,CAAA;AAC9C,QAAQ,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,CAAC,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAgC;IAClD,GAAG,IAAI,CAAC,MAAM,CACZ,CAAC,MAAM,EAAuC,EAAE,CAC9C,CAAC,UAAU,EAAE,iBAAiB,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAC9D;CACF,CAAA"}