@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.
- package/README.md +216 -38
- package/agent-meta/manifest.json +10 -3
- package/agent-meta/skills/server-auth/SKILL.md +161 -22
- package/agent-meta/skills/supervisor-auth/SKILL.md +128 -0
- package/build/actions/service.d.ts +6 -2
- package/build/actions/service.d.ts.map +1 -1
- package/build/actions/service.js +5 -4
- package/build/actions/service.js.map +1 -1
- package/build/actions/types.local.d.ts +6 -0
- package/build/actions/types.local.d.ts.map +1 -0
- package/build/actions/types.local.js +2 -0
- package/build/actions/types.local.js.map +1 -0
- package/build/entrypoints.d.ts +5 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +9 -0
- package/build/entrypoints.js.map +1 -0
- package/build/index.d.ts +2 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1
- package/build/index.js.map +1 -1
- package/build/manager/actions/authenticate.d.ts +8 -0
- package/build/manager/actions/authenticate.d.ts.map +1 -0
- package/build/manager/actions/authenticate.js +5 -0
- package/build/manager/actions/authenticate.js.map +1 -0
- package/build/manager/actions/index.d.ts +3 -1
- package/build/manager/actions/index.d.ts.map +1 -1
- package/build/manager/actions/index.js +3 -1
- package/build/manager/actions/index.js.map +1 -1
- package/build/manager/actions/init.d.ts +8 -0
- package/build/manager/actions/init.d.ts.map +1 -0
- package/build/manager/actions/init.js +5 -0
- package/build/manager/actions/init.js.map +1 -0
- package/build/manager/actions/rely.d.ts +8 -0
- package/build/manager/actions/rely.d.ts.map +1 -0
- package/build/manager/actions/rely.js +7 -0
- package/build/manager/actions/rely.js.map +1 -0
- package/build/manager/consts.local.d.ts +2 -0
- package/build/manager/consts.local.d.ts.map +1 -0
- package/build/manager/consts.local.js +2 -0
- package/build/manager/consts.local.js.map +1 -0
- package/build/manager/context.d.ts.map +1 -1
- package/build/manager/context.js +0 -1
- package/build/manager/context.js.map +1 -1
- package/build/manager/entrypoints.d.ts +9 -0
- package/build/manager/entrypoints.d.ts.map +1 -0
- package/build/manager/entrypoints.js +16 -0
- package/build/manager/entrypoints.js.map +1 -0
- package/build/manager/exports.d.ts +1 -2
- package/build/manager/exports.d.ts.map +1 -1
- package/build/manager/exports.js +1 -2
- package/build/manager/exports.js.map +1 -1
- package/build/manager/index.d.ts +1 -1
- package/build/manager/index.d.ts.map +1 -1
- package/build/manager/index.js +1 -1
- package/build/manager/index.js.map +1 -1
- package/build/manager/main.d.ts.map +1 -1
- package/build/manager/main.js +2 -2
- package/build/manager/main.js.map +1 -1
- package/build/manager/model.d.ts.map +1 -1
- package/build/manager/model.js +71 -32
- package/build/manager/model.js.map +1 -1
- package/build/manager/plugins/basic-ed25519.d.ts +1 -1
- package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
- package/build/manager/plugins/basic-ed25519.js +6 -6
- package/build/manager/plugins/basic-ed25519.js.map +1 -1
- package/build/manager/plugins/basic-rely.d.ts +1 -1
- package/build/manager/plugins/basic-rely.d.ts.map +1 -1
- package/build/manager/plugins/basic-rely.js +11 -8
- package/build/manager/plugins/basic-rely.js.map +1 -1
- package/build/manager/plugins/consts.d.ts +8 -0
- package/build/manager/plugins/consts.d.ts.map +1 -0
- package/build/manager/plugins/consts.js +9 -0
- package/build/manager/plugins/consts.js.map +1 -0
- package/build/manager/plugins/export.d.ts +5 -0
- package/build/manager/plugins/export.d.ts.map +1 -1
- package/build/manager/plugins/export.js +4 -0
- package/build/manager/plugins/export.js.map +1 -1
- package/build/manager/plugins/index.d.ts +3 -4
- package/build/manager/plugins/index.d.ts.map +1 -1
- package/build/manager/plugins/index.js +6 -6
- package/build/manager/plugins/index.js.map +1 -1
- package/build/manager/plugins/re-captcha/consts.d.ts +7 -0
- package/build/manager/plugins/re-captcha/consts.d.ts.map +1 -0
- package/build/manager/plugins/re-captcha/consts.js +7 -0
- package/build/manager/plugins/re-captcha/consts.js.map +1 -0
- package/build/manager/plugins/re-captcha/consts.local.d.ts +7 -0
- package/build/manager/plugins/re-captcha/consts.local.d.ts.map +1 -0
- package/build/manager/plugins/re-captcha/consts.local.js +9 -0
- package/build/manager/plugins/re-captcha/consts.local.js.map +1 -0
- package/build/manager/plugins/re-captcha/policy.d.ts +3 -0
- package/build/manager/plugins/re-captcha/policy.d.ts.map +1 -0
- package/build/manager/plugins/re-captcha/policy.js +44 -0
- package/build/manager/plugins/re-captcha/policy.js.map +1 -0
- package/build/manager/plugins/re-captcha/types.d.ts +79 -0
- package/build/manager/plugins/re-captcha/types.d.ts.map +1 -0
- package/build/manager/plugins/re-captcha/types.js +2 -0
- package/build/manager/plugins/re-captcha/types.js.map +1 -0
- package/build/manager/plugins/re-captcha/verifier.d.ts +12 -0
- package/build/manager/plugins/re-captcha/verifier.d.ts.map +1 -0
- package/build/manager/plugins/re-captcha/verifier.js +62 -0
- package/build/manager/plugins/re-captcha/verifier.js.map +1 -0
- package/build/manager/plugins/re-captcha.d.ts +10 -1
- package/build/manager/plugins/re-captcha.d.ts.map +1 -1
- package/build/manager/plugins/re-captcha.js +50 -37
- package/build/manager/plugins/re-captcha.js.map +1 -1
- package/build/manager/plugins/supervisor.d.ts +1 -2
- package/build/manager/plugins/supervisor.d.ts.map +1 -1
- package/build/manager/plugins/supervisor.js +6 -6
- package/build/manager/plugins/supervisor.js.map +1 -1
- package/build/manager/plugins/types.d.ts +22 -14
- package/build/manager/plugins/types.d.ts.map +1 -1
- package/build/manager/plugins/utils.d.ts +5 -4
- package/build/manager/plugins/utils.d.ts.map +1 -1
- package/build/manager/plugins/utils.js +20 -15
- package/build/manager/plugins/utils.js.map +1 -1
- package/build/manager/rely/flow.d.ts.map +1 -1
- package/build/manager/rely/flow.js +13 -11
- package/build/manager/rely/flow.js.map +1 -1
- package/build/manager/rely.d.ts.map +1 -1
- package/build/manager/rely.js.map +1 -1
- package/build/manager/supervisor.d.ts +4 -45
- package/build/manager/supervisor.d.ts.map +1 -1
- package/build/manager/supervisor.js +5 -5
- package/build/manager/supervisor.js.map +1 -1
- package/build/manager/types.d.ts +41 -1
- package/build/manager/types.d.ts.map +1 -1
- package/build/manager/types.local.d.ts +4 -0
- package/build/manager/types.local.d.ts.map +1 -0
- package/build/manager/types.local.js +2 -0
- package/build/manager/types.local.js.map +1 -0
- package/build/manager/utils/trusted.d.ts.map +1 -1
- package/build/manager/utils/trusted.js.map +1 -1
- package/build/model/rely/consumer.d.ts +3 -1
- package/build/model/rely/consumer.d.ts.map +1 -1
- package/build/model/rely/consumer.js.map +1 -1
- package/build/model/rely/peer.d.ts.map +1 -1
- package/build/model/rely/peer.js +7 -7
- package/build/model/rely/peer.js.map +1 -1
- package/build/model/rely/provider.d.ts +3 -1
- package/build/model/rely/provider.d.ts.map +1 -1
- package/build/model/rely/provider.js.map +1 -1
- package/build/re-captcha/guard.d.ts +12 -0
- package/build/re-captcha/guard.d.ts.map +1 -0
- package/build/re-captcha/guard.js +43 -0
- package/build/re-captcha/guard.js.map +1 -0
- package/build/re-captcha/index.d.ts +4 -0
- package/build/re-captcha/index.d.ts.map +1 -0
- package/build/re-captcha/index.js +3 -0
- package/build/re-captcha/index.js.map +1 -0
- package/build/re-captcha/token.d.ts +6 -0
- package/build/re-captcha/token.d.ts.map +1 -0
- package/build/re-captcha/token.js +74 -0
- package/build/re-captcha/token.js.map +1 -0
- package/build/re-captcha/types.d.ts +44 -0
- package/build/re-captcha/types.d.ts.map +1 -0
- package/build/re-captcha/types.js +2 -0
- package/build/re-captcha/types.js.map +1 -0
- package/build/service.d.ts +2 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +136 -13
- package/build/service.js.map +1 -1
- package/build/types.local.d.ts +4 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/package.json +33 -30
- package/src/actions/service.ts +10 -12
- package/src/actions/types.local.ts +6 -0
- package/src/entrypoints.ts +12 -0
- package/src/index.ts +2 -2
- package/src/manager/README.md +14 -18
- package/src/manager/actions/authenticate.ts +12 -0
- package/src/manager/actions/index.ts +3 -1
- package/src/manager/actions/init.ts +12 -0
- package/src/manager/actions/rely.ts +12 -0
- package/src/manager/consts.local.ts +1 -0
- package/src/manager/context.ts +0 -2
- package/src/manager/entrypoints.ts +17 -0
- package/src/manager/exports.ts +1 -2
- package/src/manager/index.ts +1 -1
- package/src/manager/main.ts +2 -2
- package/src/manager/model.ts +82 -41
- package/src/manager/plugins/basic-ed25519.ts +8 -8
- package/src/manager/plugins/basic-rely.ts +14 -9
- package/src/manager/plugins/consts.ts +7 -0
- package/src/manager/plugins/export.ts +5 -0
- package/src/manager/plugins/index.ts +10 -12
- package/src/manager/plugins/re-captcha/consts.local.ts +8 -0
- package/src/manager/plugins/re-captcha/consts.ts +8 -0
- package/src/manager/plugins/re-captcha/policy.ts +56 -0
- package/src/manager/plugins/re-captcha/types.ts +85 -0
- package/src/manager/plugins/re-captcha/verifier.ts +74 -0
- package/src/manager/plugins/re-captcha.ts +58 -43
- package/src/manager/plugins/supervisor.ts +9 -11
- package/src/manager/plugins/types.ts +23 -14
- package/src/manager/plugins/utils.ts +24 -14
- package/src/manager/rely/flow.ts +14 -11
- package/src/manager/rely.ts +2 -5
- package/src/manager/supervisor.ts +8 -54
- package/src/manager/types.local.ts +5 -0
- package/src/manager/types.ts +46 -1
- package/src/model/rely/consumer.ts +3 -1
- package/src/model/rely/peer.ts +8 -8
- package/src/model/rely/provider.ts +3 -1
- package/src/re-captcha/guard.ts +52 -0
- package/src/re-captcha/index.ts +3 -0
- package/src/re-captcha/token.ts +89 -0
- package/src/re-captcha/types.ts +45 -0
- package/src/service.ts +133 -19
- package/src/types.local.ts +5 -0
- package/tests/context.ts +3 -2
- package/tests/re-captcha.spec.ts +298 -0
- package/tests/replay-policy.spec.ts +36 -0
- package/tests/supervisor.spec.ts +6 -4
- package/build/.gitkeep +0 -0
- package/src/manager/actions/manager.ts +0 -23
- package/src/manager/modules.ts +0 -29
- package/src/modules.ts +0 -17
package/src/manager/model.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import type { AuthModel, AppConfig, AppContext } from './types.js'
|
|
2
|
-
import { AuthenFailed, AuthenPayloadError } from '@owlmeans/auth'
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
45
|
-
const
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const msg: string = envelope.message(true)
|
|
62
|
+
|
|
63
|
+
const plugin = await authPluginHelper.getPlugin(envelope.type(), context)
|
|
65
64
|
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
104
|
+
credential.credential = trustedUser.id
|
|
79
105
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
13
|
-
|
|
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
|
|
20
|
-
//
|
|
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.
|
|
45
|
-
if (value
|
|
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
|
-
}, {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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,
|
|
7
|
+
export const plugins: Record<string, AuthPluginFactory> = {}
|
|
9
8
|
|
|
10
|
-
plugins[AuthenticationType.BasicEd25519] =
|
|
11
|
-
plugins[AuthenticationType.ReCaptcha] =
|
|
12
|
-
plugins[AuthenticationType.RelyHandshake] =
|
|
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:
|
|
16
|
+
factory: AuthPluginFactory
|
|
18
17
|
): void => {
|
|
19
|
-
plugins[type] = factory
|
|
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
|
+
}
|