@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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
AUTH_SCOPE, AuthenFailed, AuthenticationType, AuthRole, AuthUnavailable, GUEST_ID, MOD_RECAPTCHA, RECAPTCHA_GUARD,
|
|
4
|
+
} from '@owlmeans/auth'
|
|
5
|
+
import type { Auth, AuthCredentials } from '@owlmeans/auth'
|
|
6
|
+
import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
|
|
7
|
+
import type { KeyPairModel } from '@owlmeans/basic-keys'
|
|
8
|
+
import { appendConfigResource, PLUGIN_RECORD, PLUGINS, PluginMissconfigured } from '@owlmeans/config'
|
|
9
|
+
import { createService } from '@owlmeans/context'
|
|
10
|
+
import type { GuardService } from '@owlmeans/entrypoint'
|
|
11
|
+
import { makeFixtureKeyPair } from '@owlmeans/test-auth'
|
|
12
|
+
import { AUTHEN_TIMEFRAME, DEFAULT_ALIAS } from '../src/consts.js'
|
|
13
|
+
import { makeAuthModel } from '../src/manager/model.js'
|
|
14
|
+
import {
|
|
15
|
+
createReCaptchaVerifierService, makeReCaptchaPolicyModel, RECAPTCHA_SITEVERIFY_URL, RECAPTCHA_VERIFIER,
|
|
16
|
+
} from '../src/manager/plugins/export.js'
|
|
17
|
+
import type { ReCaptchaFetch, ReCaptchaPluginConfig, ReCaptchaRequest, ReCaptchaResponse, ReCaptchaVerifierService } from '../src/manager/plugins/export.js'
|
|
18
|
+
import { makeReCaptchaGuard, reCaptchaTokenOf } from '../src/index.js'
|
|
19
|
+
import type { AuthService } from '../src/types.js'
|
|
20
|
+
import { makeTestContext } from './context.js'
|
|
21
|
+
|
|
22
|
+
const POLICY: ReCaptchaPluginConfig = {
|
|
23
|
+
id: MOD_RECAPTCHA, value: 'recaptcha-secret', hostnames: 'owlmeans.org, owlmeans.com', minScore: '0.5', actions: 'inquiry',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const GOOD: ReCaptchaResponse = {
|
|
27
|
+
success: true, challenge_ts: '2026-10-06T10:00:00Z', hostname: 'vib-dev-1.owlmeans.org', score: 0.9, action: 'inquiry',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The auth manager and an API server in one context: the auth service's trusted record, the auth
|
|
32
|
+
* cache, the `MOD_RECAPTCHA` plugin record, a stand-in verifier that answers `answer` (no network),
|
|
33
|
+
* the bearer guard and the reCAPTCHA guard.
|
|
34
|
+
*/
|
|
35
|
+
const setup = async (record: Partial<ReCaptchaPluginConfig> | null = POLICY, answer: ReCaptchaResponse = GOOD) => {
|
|
36
|
+
const fixture = makeTestContext()
|
|
37
|
+
const { context } = fixture
|
|
38
|
+
const cfg = context.cfg as unknown as Record<string, unknown>
|
|
39
|
+
cfg[PLUGIN_RECORD] = record == null ? [] : [{ ...record }]
|
|
40
|
+
appendConfigResource(context as never, PLUGINS, PLUGIN_RECORD)
|
|
41
|
+
|
|
42
|
+
const asked: ReCaptchaRequest[] = []
|
|
43
|
+
const verifier = createService<ReCaptchaVerifierService>(RECAPTCHA_VERIFIER, {
|
|
44
|
+
verify: async request => { asked.push(request); return answer },
|
|
45
|
+
})
|
|
46
|
+
context.registerService(verifier)
|
|
47
|
+
context.registerService(makeReCaptchaGuard())
|
|
48
|
+
|
|
49
|
+
context.configure()
|
|
50
|
+
await context.init()
|
|
51
|
+
|
|
52
|
+
const model = makeAuthModel(context as never)
|
|
53
|
+
const guard = context.service<GuardService>(RECAPTCHA_GUARD)
|
|
54
|
+
const bearer = context.service<AuthService>(DEFAULT_ALIAS)
|
|
55
|
+
|
|
56
|
+
return { ...fixture, model, guard, bearer, asked }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Setup = Awaited<ReturnType<typeof setup>>
|
|
60
|
+
|
|
61
|
+
/** A sign-in through the manager, posting whatever identity the caller likes. */
|
|
62
|
+
const signIn = async ({ model }: Setup, overrides: Record<string, unknown> = {}): Promise<{ token: string, challenge: string }> => {
|
|
63
|
+
const { challenge } = await model.init({ type: AuthenticationType.ReCaptcha, userId: 'anyone' })
|
|
64
|
+
const { token } = await model.authenticate({
|
|
65
|
+
type: AuthenticationType.ReCaptcha, challenge, credential: 'google-response-token',
|
|
66
|
+
userId: 'victim@example.com', role: AuthRole.Admin, scopes: ['*'],
|
|
67
|
+
...overrides,
|
|
68
|
+
} as AuthCredentials)
|
|
69
|
+
|
|
70
|
+
return { token, challenge }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A token signed directly with `key` — to forge every shape the guard must refuse. */
|
|
74
|
+
const forged = async (
|
|
75
|
+
key: KeyPairModel, credential: Partial<AuthCredentials>, { type = AuthenticationType.ReCaptcha as string, ttl = AUTHEN_TIMEFRAME as number | null } = {}
|
|
76
|
+
): Promise<string> => await makeEnvelopeModel<AuthCredentials>(type).send({
|
|
77
|
+
type: AuthenticationType.ReCaptcha, role: AuthRole.Guest, userId: GUEST_ID, scopes: [AUTH_SCOPE],
|
|
78
|
+
challenge: `challenge-${crypto.randomUUID()}`, ...credential,
|
|
79
|
+
} as AuthCredentials, ttl).sign(key, EnvelopeKind.Token)
|
|
80
|
+
|
|
81
|
+
const admit = async ({ guard }: Setup, token: string): Promise<Auth | null> => {
|
|
82
|
+
let resolved: Auth | null = null
|
|
83
|
+
const req = { headers: { authorization: `RE-CAPTCHA ${token}` } } as never
|
|
84
|
+
const res = { resolve: (auth: Auth) => { resolved = auth } } as never
|
|
85
|
+
|
|
86
|
+
return await guard.handle<boolean>(req, res) ? resolved : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const refusal = async (promise: Promise<unknown>): Promise<Error> => {
|
|
90
|
+
try {
|
|
91
|
+
await promise
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return error as Error
|
|
94
|
+
}
|
|
95
|
+
throw new Error('expected a refusal')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('@owlmeans/server-auth — reCAPTCHA plugin', () => {
|
|
99
|
+
test('signs a guest whatever identity the caller posted, after asking the verifier', async () => {
|
|
100
|
+
const env = await setup()
|
|
101
|
+
const { token, challenge } = await signIn(env, {
|
|
102
|
+
profileId: 'profile-1', entitySlug: 'acme', entityId: 'acme-id', permissions: [{ scope: 'x', permissions: [] }],
|
|
103
|
+
groups: ['admins'], expiresAt: new Date(Date.now() + 1e9), source: 'previous-challenge', publicKey: 'pk',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(env.asked).toEqual([{ secret: 'recaptcha-secret', response: 'google-response-token' }])
|
|
107
|
+
|
|
108
|
+
const envelope = makeEnvelopeModel<AuthCredentials>(token, EnvelopeKind.Token)
|
|
109
|
+
expect(envelope.type()).toBe(AuthenticationType.ReCaptcha)
|
|
110
|
+
const credential = envelope.message<AuthCredentials>()
|
|
111
|
+
expect(credential).toEqual({
|
|
112
|
+
type: AuthenticationType.ReCaptcha, role: AuthRole.Guest, userId: GUEST_ID, scopes: [AUTH_SCOPE],
|
|
113
|
+
// The server-issued challenge is the spend key; the stamp is the auth record's id.
|
|
114
|
+
challenge, credential: env.authServiceRecord.id,
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('applies the policy of the plugin record: hostname, score, action, Google\'s own refusal', async () => {
|
|
119
|
+
const cases: [Partial<ReCaptchaResponse>, string | null][] = [
|
|
120
|
+
[{ hostname: 'owlmeans.com' }, null],
|
|
121
|
+
[{ hostname: 'Platform.OwlMeans.com.' }, null],
|
|
122
|
+
[{ hostname: 'evil.com' }, 'recaptcha:hostname'],
|
|
123
|
+
[{ hostname: 'notowlmeans.org' }, 'recaptcha:hostname'],
|
|
124
|
+
[{ hostname: '' }, 'recaptcha:hostname'],
|
|
125
|
+
[{ score: 0.3 }, 'recaptcha:score'],
|
|
126
|
+
[{ score: 0.5 }, null],
|
|
127
|
+
[{ score: undefined }, null],
|
|
128
|
+
[{ action: 'login' }, 'recaptcha:action'],
|
|
129
|
+
[{ action: undefined }, 'recaptcha:action'],
|
|
130
|
+
[{ success: false, 'error-codes': ['timeout-or-duplicate'] }, 'recaptcha:timeout-or-duplicate'],
|
|
131
|
+
[{ success: false }, 'recaptcha:unknown'],
|
|
132
|
+
]
|
|
133
|
+
for (const [answer, reason] of cases) {
|
|
134
|
+
const env = await setup(POLICY, { ...GOOD, ...answer })
|
|
135
|
+
if (reason == null) {
|
|
136
|
+
expect(await signIn(env)).toHaveProperty('token')
|
|
137
|
+
} else {
|
|
138
|
+
const error = await refusal(signIn(env))
|
|
139
|
+
expect([answer, error]).toEqual([answer, expect.any(AuthenFailed)])
|
|
140
|
+
expect(error.message).toContain(reason)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('an empty policy field is no check; no secret is no reCAPTCHA sign-in', async () => {
|
|
146
|
+
const open = await setup({ id: MOD_RECAPTCHA, value: 'recaptcha-secret', hostnames: '', minScore: '', actions: '' }, {
|
|
147
|
+
success: true, challenge_ts: '2026-10-06T10:00:00Z', hostname: 'anywhere.example',
|
|
148
|
+
})
|
|
149
|
+
expect(await signIn(open)).toHaveProperty('token')
|
|
150
|
+
|
|
151
|
+
for (const record of [{ id: MOD_RECAPTCHA, value: '' }, null]) {
|
|
152
|
+
const env = await setup(record)
|
|
153
|
+
expect(await refusal(signIn(env))).toBeInstanceOf(PluginMissconfigured)
|
|
154
|
+
expect(env.asked).toHaveLength(0)
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('the policy model parses the record as a mounted file delivers it', () => {
|
|
159
|
+
const policy = makeReCaptchaPolicyModel({ id: MOD_RECAPTCHA, hostnames: ' a.org,b.com *.c.net ', minScore: '0.7', actions: 'inquiry, contact' })
|
|
160
|
+
|
|
161
|
+
expect(policy.hostnames()).toEqual(['a.org', 'b.com', 'c.net'])
|
|
162
|
+
expect(policy.minScore()).toBe(0.7)
|
|
163
|
+
expect(policy.actions()).toEqual(['inquiry', 'contact'])
|
|
164
|
+
expect(makeReCaptchaPolicyModel({ id: MOD_RECAPTCHA, minScore: 0 }).minScore()).toBe(0)
|
|
165
|
+
for (const minScore of ['high', '1.5', '-0.1']) {
|
|
166
|
+
expect(() => makeReCaptchaPolicyModel({ id: MOD_RECAPTCHA, minScore }).minScore()).toThrow(PluginMissconfigured)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('@owlmeans/server-auth — the default reCAPTCHA verifier', () => {
|
|
172
|
+
const answering = (status: number, body: unknown, calls: { url: string, init: RequestInit }[] = []): ReCaptchaFetch =>
|
|
173
|
+
async (url, init) => {
|
|
174
|
+
calls.push({ url, init })
|
|
175
|
+
return new Response(JSON.stringify(body), { status })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('posts the siteverify form to Google over https', async () => {
|
|
179
|
+
const calls: { url: string, init: RequestInit }[] = []
|
|
180
|
+
const verifier = createReCaptchaVerifierService(RECAPTCHA_VERIFIER, { fetch: answering(200, GOOD, calls) })
|
|
181
|
+
|
|
182
|
+
expect(await verifier.verify({ secret: 's', response: 'r', remoteip: '203.0.113.7' })).toEqual(GOOD)
|
|
183
|
+
expect(calls).toHaveLength(1)
|
|
184
|
+
expect(calls[0].url).toBe(RECAPTCHA_SITEVERIFY_URL)
|
|
185
|
+
expect(RECAPTCHA_SITEVERIFY_URL).toStartWith('https://www.google.com/')
|
|
186
|
+
expect(calls[0].init.method).toBe('POST')
|
|
187
|
+
expect(Object.fromEntries(calls[0].init.body as URLSearchParams)).toEqual({ secret: 's', response: 'r', remoteip: '203.0.113.7' })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('an unreachable or unreadable Google is unavailability, not a refusal', async () => {
|
|
191
|
+
const failing: ReCaptchaFetch = async () => { throw new Error('offline') }
|
|
192
|
+
for (const fetch of [failing, answering(500, {}), answering(200, { nothing: true })]) {
|
|
193
|
+
await expect(createReCaptchaVerifierService(RECAPTCHA_VERIFIER, { fetch }).verify({ secret: 's', response: 'r' }))
|
|
194
|
+
.rejects.toBeInstanceOf(AuthUnavailable)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('refuses a siteverify address that is not https', () => {
|
|
199
|
+
expect(() => createReCaptchaVerifierService(RECAPTCHA_VERIFIER, { url: 'http://www.google.com/recaptcha/api/siteverify' }))
|
|
200
|
+
.toThrow(SyntaxError)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('@owlmeans/server-auth — reCAPTCHA guard', () => {
|
|
205
|
+
test('admits a manager-issued guest token once, as a guest that carries no token', async () => {
|
|
206
|
+
const env = await setup()
|
|
207
|
+
const { token } = await signIn(env, { profileId: 'profile-1', entitySlug: 'acme' })
|
|
208
|
+
|
|
209
|
+
expect(await env.guard.match({ headers: { authorization: `RE-CAPTCHA ${token}` } } as never, {} as never)).toBe(true)
|
|
210
|
+
const auth = await admit(env, token)
|
|
211
|
+
|
|
212
|
+
expect(auth).toMatchObject({
|
|
213
|
+
token: '', type: AuthenticationType.ReCaptcha, role: AuthRole.Guest, userId: GUEST_ID, scopes: [AUTH_SCOPE], isUser: false,
|
|
214
|
+
})
|
|
215
|
+
expect(auth?.profileId).toBeUndefined()
|
|
216
|
+
expect(auth?.entitySlug).toBeUndefined()
|
|
217
|
+
expect(auth?.expiresAt?.getTime()).toBeGreaterThan(Date.now())
|
|
218
|
+
// Replay: the same token never admits a second request.
|
|
219
|
+
expect(await admit(env, token)).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('inspection does not spend: the guard still admits after it', async () => {
|
|
223
|
+
const env = await setup()
|
|
224
|
+
const { token, challenge } = await signIn(env)
|
|
225
|
+
const tokens = reCaptchaTokenOf(env.context as never)
|
|
226
|
+
const req = { headers: { authorization: `RE-CAPTCHA ${token}` } }
|
|
227
|
+
|
|
228
|
+
expect((await tokens.inspect(req))?.challenge).toBe(challenge)
|
|
229
|
+
expect(await tokens.inspect(req)).not.toBeNull()
|
|
230
|
+
expect(await admit(env, token)).not.toBeNull()
|
|
231
|
+
// Spent now — but inspection judges only the token, the guard judges the spend.
|
|
232
|
+
expect(await tokens.inspect(req)).not.toBeNull()
|
|
233
|
+
expect(await admit(env, token)).toBeNull()
|
|
234
|
+
expect(await tokens.inspect({ headers: {} })).toBeNull()
|
|
235
|
+
expect(await tokens.inspect({ headers: { authorization: `ED25519-BASIC-TOKEN ${token}` } })).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('refuses a wrong type, a non-guest, a foreign stamp, a bad signature, an expired or long-lived token', async () => {
|
|
239
|
+
const env = await setup()
|
|
240
|
+
const key = env.authServiceKP
|
|
241
|
+
const stamp = env.authServiceRecord.id
|
|
242
|
+
|
|
243
|
+
const refused = [
|
|
244
|
+
// Another method's token under the auth key, whatever it claims inside.
|
|
245
|
+
await forged(key, { credential: stamp, type: AuthenticationType.OneTimeToken }, { type: AuthenticationType.OneTimeToken }),
|
|
246
|
+
await forged(key, { credential: stamp }, { type: AuthenticationType.OneTimeToken }),
|
|
247
|
+
await forged(key, { credential: stamp, type: AuthenticationType.OneTimeToken }),
|
|
248
|
+
await forged(key, { credential: stamp, role: AuthRole.User }),
|
|
249
|
+
await forged(key, { credential: stamp, userId: 'person@example.com' }),
|
|
250
|
+
await forged(key, { credential: 'someone-else' }),
|
|
251
|
+
await forged(key, { credential: stamp, challenge: '' }),
|
|
252
|
+
await forged(makeFixtureKeyPair('foreign-signer'), { credential: stamp }),
|
|
253
|
+
await forged(key, { credential: stamp }, { ttl: null }),
|
|
254
|
+
await forged(key, { credential: stamp }, { ttl: AUTHEN_TIMEFRAME + 1 }),
|
|
255
|
+
'not-a-token',
|
|
256
|
+
]
|
|
257
|
+
const expired = await forged(key, { credential: stamp }, { ttl: 1 })
|
|
258
|
+
await Bun.sleep(5)
|
|
259
|
+
refused.push(expired)
|
|
260
|
+
|
|
261
|
+
for (const token of refused) {
|
|
262
|
+
expect([token, await admit(env, token)]).toEqual([token, null])
|
|
263
|
+
}
|
|
264
|
+
// The same shape, well formed, is admitted.
|
|
265
|
+
expect(await admit(env, await forged(key, { credential: stamp }))).not.toBeNull()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
test('does not match another authorization scheme', async () => {
|
|
269
|
+
const env = await setup()
|
|
270
|
+
|
|
271
|
+
expect(await env.guard.match({ headers: { authorization: 'ED25519-BASIC-TOKEN abc' } } as never, {} as never)).toBe(false)
|
|
272
|
+
expect(await env.guard.match({ headers: {} } as never, {} as never)).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
describe('@owlmeans/server-auth — a guest token never becomes a session', () => {
|
|
277
|
+
test('the bearer exchange refuses a reCAPTCHA token, and leaves it unspent', async () => {
|
|
278
|
+
const env = await setup()
|
|
279
|
+
const { token } = await signIn(env)
|
|
280
|
+
|
|
281
|
+
expect(await refusal(env.bearer.authenticate({ token }))).toBeInstanceOf(AuthenFailed)
|
|
282
|
+
expect(await admit(env, token)).not.toBeNull()
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('also when only the envelope or only the credential says reCAPTCHA', async () => {
|
|
286
|
+
const env = await setup()
|
|
287
|
+
const stamp = env.authServiceRecord.id
|
|
288
|
+
|
|
289
|
+
for (const token of [
|
|
290
|
+
await forged(env.authServiceKP, { credential: stamp, type: AuthenticationType.OneTimeToken, role: AuthRole.User, userId: 'u' }),
|
|
291
|
+
await forged(env.authServiceKP, { credential: stamp }, { type: AuthenticationType.OneTimeToken }),
|
|
292
|
+
]) {
|
|
293
|
+
const error = await refusal(env.bearer.authenticate({ token }))
|
|
294
|
+
expect(error).toBeInstanceOf(AuthenFailed)
|
|
295
|
+
expect(error.message).toContain('guest')
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -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/consts.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
|
+
})
|
package/tests/supervisor.spec.ts
CHANGED
|
@@ -3,15 +3,17 @@ 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'
|
|
6
7
|
import { makeSupervisorPlugin } from '../src/manager/plugins/supervisor.js'
|
|
7
8
|
import type { AppConfig, AppContext } from '../src/manager/types.js'
|
|
9
|
+
import { recordQueryHelper } from '@owlmeans/resource'
|
|
8
10
|
|
|
9
11
|
const SUPERVISOR = 'master'
|
|
10
12
|
|
|
11
13
|
const makeStubContext = (records: TrustedRecord[]): AppContext<AppConfig> => ({
|
|
12
14
|
getConfigResource: () => ({
|
|
13
|
-
load: async (
|
|
14
|
-
|
|
15
|
+
load: async (idOrWhere: string | Criteria<TrustedRecord>) =>
|
|
16
|
+
recordQueryHelper.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',
|
|
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.
|
|
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,23 +0,0 @@
|
|
|
1
|
-
import type { AllowanceRequest, AuthCredentials } from '@owlmeans/auth'
|
|
2
|
-
import { handleBody } from '@owlmeans/server-api'
|
|
3
|
-
import { makeAuthModel } from '../model.js'
|
|
4
|
-
import type { RefedEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
5
|
-
import type { AppContext, AppConfig } from '../types.js'
|
|
6
|
-
import { handleConnection } from '@owlmeans/server-socket'
|
|
7
|
-
|
|
8
|
-
export const authenticationInit: RefedEntrypointHandler<AllowanceRequest> = handleBody(
|
|
9
|
-
async (payload: AllowanceRequest, ctx) =>
|
|
10
|
-
await makeAuthModel(ctx as AppContext<AppConfig>).init(payload)
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
export const authenticate: RefedEntrypointHandler<AuthCredentials> = handleBody(
|
|
14
|
-
async (payload: AuthCredentials, ctx) =>
|
|
15
|
-
await makeAuthModel(ctx as AppContext<AppConfig>).authenticate(payload)
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
export const rely: RefedEntrypointHandler<void> = handleConnection(
|
|
19
|
-
// @TODO Request will contain information is there requrest
|
|
20
|
-
// privileged or not (privileged request implies auth provider)
|
|
21
|
-
async (conn, ctx, req) =>
|
|
22
|
-
await makeAuthModel(ctx as AppContext<AppConfig>).rely(conn, req.auth)
|
|
23
|
-
)
|
package/src/manager/modules.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { AUTHEN, AUTHEN_AUTHEN, AUTHEN_INIT, AUTHEN_RELY, MOD_RECAPTCHA } from '@owlmeans/auth'
|
|
3
|
-
import { modules as list } from '@owlmeans/auth-common'
|
|
4
|
-
import { modules as apiConfig } from '@owlmeans/api-config-server'
|
|
5
|
-
import { elevate, guard } from '@owlmeans/server-entrypoint'
|
|
6
|
-
import * as actions from './actions/index.js'
|
|
7
|
-
import { entrypoint } from '@owlmeans/client-entrypoint'
|
|
8
|
-
import { backend, route, RouteMethod } from '@owlmeans/route'
|
|
9
|
-
import { handleIntermediate } from '@owlmeans/server-api'
|
|
10
|
-
import { DEFAULT_RELY } from './consts.js'
|
|
11
|
-
|
|
12
|
-
elevate(list, AUTHEN, handleIntermediate(
|
|
13
|
-
async (_, context) => await context.updateContext() as typeof context
|
|
14
|
-
), { intermediate: true })
|
|
15
|
-
elevate(list, AUTHEN_INIT, actions.authenticationInit)
|
|
16
|
-
elevate(list, AUTHEN_AUTHEN, actions.authenticate)
|
|
17
|
-
elevate(list, AUTHEN_RELY, actions.rely, guard(DEFAULT_RELY))
|
|
18
|
-
|
|
19
|
-
export const modules = list
|
|
20
|
-
list.push(...apiConfig)
|
|
21
|
-
list.push(
|
|
22
|
-
entrypoint(
|
|
23
|
-
route(MOD_RECAPTCHA, '/api/siteverify', backend({
|
|
24
|
-
host: 'https://www.google.com',
|
|
25
|
-
base: 'recaptcha',
|
|
26
|
-
secure: true,
|
|
27
|
-
}, RouteMethod.POST))
|
|
28
|
-
)
|
|
29
|
-
)
|
package/src/modules.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import { elevate } from '@owlmeans/server-entrypoint'
|
|
3
|
-
import { modules as list } from '@owlmeans/auth-common'
|
|
4
|
-
import { DISPATCHER, DISPATCHER_AUTHEN } from '@owlmeans/auth'
|
|
5
|
-
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
6
|
-
import { authenticate } from './actions/service.js'
|
|
7
|
-
import { stab, elevate as celevate } from '@owlmeans/client-entrypoint'
|
|
8
|
-
|
|
9
|
-
elevate(list, DISPATCHER_AUTHEN, authenticate)
|
|
10
|
-
celevate(list, DISPATCHER, stab)
|
|
11
|
-
|
|
12
|
-
export const modules: ServerEntrypoint<unknown>[] = [
|
|
13
|
-
...list.filter(
|
|
14
|
-
(module): module is ServerEntrypoint<unknown> =>
|
|
15
|
-
[DISPATCHER, DISPATCHER_AUTHEN].includes(module.getAlias())
|
|
16
|
-
)
|
|
17
|
-
]
|