@owlmeans/server-auth 0.1.18-rc.55 → 0.1.18-rc.57
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 +36 -4
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth/SKILL.md +64 -4
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/manager/entrypoints.d.ts.map +1 -1
- package/build/manager/entrypoints.js +1 -9
- package/build/manager/entrypoints.js.map +1 -1
- package/build/manager/plugins/export.d.ts +4 -0
- package/build/manager/plugins/export.d.ts.map +1 -1
- package/build/manager/plugins/export.js +3 -0
- package/build/manager/plugins/export.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 +9 -0
- package/build/manager/plugins/re-captcha.d.ts.map +1 -1
- package/build/manager/plugins/re-captcha.js +46 -33
- package/build/manager/plugins/re-captcha.js.map +1 -1
- package/build/manager/plugins/types.d.ts +0 -13
- package/build/manager/plugins/types.d.ts.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.map +1 -1
- package/build/service.js +7 -2
- package/build/service.js.map +1 -1
- package/package.json +20 -20
- package/src/index.ts +1 -1
- package/src/manager/README.md +1 -1
- package/src/manager/entrypoints.ts +1 -10
- package/src/manager/plugins/export.ts +4 -0
- 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 +54 -39
- package/src/manager/plugins/types.ts +0 -14
- 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 +7 -2
- package/tests/re-captcha.spec.ts +298 -0
- package/build/manager/actions/manager.d.ts +0 -18
- package/build/manager/actions/manager.d.ts.map +0 -1
- package/build/manager/actions/manager.js +0 -11
- package/build/manager/actions/manager.js.map +0 -1
- package/build/manager/plugins/replay-policy.d.ts +0 -8
- package/build/manager/plugins/replay-policy.d.ts.map +0 -1
- package/build/manager/plugins/replay-policy.js +0 -9
- package/build/manager/plugins/replay-policy.js.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -1,59 +1,74 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import
|
|
4
|
-
import { PLUGINS } from '@owlmeans/config'
|
|
5
|
-
import type { PluginConfig } from '@owlmeans/config'
|
|
6
|
-
import { PluginMissconfigured } from '@owlmeans/config'
|
|
7
|
-
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
8
|
-
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
1
|
+
import { AUTH_SCOPE, AuthenticationType, AuthRole, GUEST_ID, MOD_RECAPTCHA } from '@owlmeans/auth'
|
|
2
|
+
import type { AuthCredentials } from '@owlmeans/auth'
|
|
3
|
+
import { PLUGINS, PluginMissconfigured } from '@owlmeans/config'
|
|
9
4
|
import { base64 } from '@scure/base'
|
|
10
5
|
import { randomBytes } from '@noble/hashes/utils.js'
|
|
6
|
+
import type { AppContext, AppConfig } from '../types.js'
|
|
7
|
+
import type { AuthPlugin } from './types.js'
|
|
11
8
|
import { authPluginHelper } from './utils.js'
|
|
9
|
+
import { RECAPTCHA_VERIFIER } from './re-captcha/consts.js'
|
|
10
|
+
import { RECAPTCHA_CREDENTIAL_FIELDS } from './re-captcha/consts.local.js'
|
|
11
|
+
import { makeReCaptchaPolicyModel } from './re-captcha/policy.js'
|
|
12
|
+
import { createReCaptchaVerifierService } from './re-captcha/verifier.js'
|
|
13
|
+
import type { ReCaptchaPluginConfig, ReCaptchaPolicyModel, ReCaptchaVerifierService } from './re-captcha/types.js'
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The reCAPTCHA sign-in: proves that a person solved a challenge, and nothing about who they are.
|
|
17
|
+
*
|
|
18
|
+
* The token the manager signs for it is a GUEST credential — `GUEST_ID`, role Guest, `[AUTH_SCOPE]`,
|
|
19
|
+
* type ReCaptcha — whatever identity the caller posted: a profile, an organization, permissions,
|
|
20
|
+
* groups, an expiry and a `source` are dropped before it is signed. Its single-use key is the
|
|
21
|
+
* server-issued challenge it carries. A route accepts it only through `makeReCaptchaGuard`; the
|
|
22
|
+
* bearer exchange refuses it.
|
|
23
|
+
*/
|
|
13
24
|
export const makeReCaptchaPlugin = <C extends AppConfig, T extends AppContext<C>>(context: T): AuthPlugin => {
|
|
25
|
+
const verifier = (): ReCaptchaVerifierService => context.hasService(RECAPTCHA_VERIFIER)
|
|
26
|
+
? context.service<ReCaptchaVerifierService>(RECAPTCHA_VERIFIER)
|
|
27
|
+
: createReCaptchaVerifierService()
|
|
28
|
+
|
|
29
|
+
/** The secret and the policy of the `MOD_RECAPTCHA` record; no secret = no reCAPTCHA sign-in. */
|
|
30
|
+
const configured = async (): Promise<[string, ReCaptchaPolicyModel]> => {
|
|
31
|
+
const record = await context.getConfigResource<ReCaptchaPluginConfig>(PLUGINS).load(MOD_RECAPTCHA)
|
|
32
|
+
if (record == null || typeof record.value !== 'string' || record.value === '') {
|
|
33
|
+
throw new PluginMissconfigured('value')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return [record.value, makeReCaptchaPolicyModel(record)]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const resetIdentity = (credential: AuthCredentials): void => {
|
|
40
|
+
const fields = credential as unknown as Record<string, unknown>
|
|
41
|
+
for (const field of Object.keys(fields)) {
|
|
42
|
+
if (!RECAPTCHA_CREDENTIAL_FIELDS.includes(field)) {
|
|
43
|
+
delete fields[field]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
credential.type = AuthenticationType.ReCaptcha
|
|
47
|
+
credential.role = AuthRole.Guest
|
|
48
|
+
credential.userId = GUEST_ID
|
|
49
|
+
credential.scopes = [AUTH_SCOPE]
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
const plugin: AuthPlugin = {
|
|
15
53
|
type: AuthenticationType.ReCaptcha,
|
|
16
54
|
|
|
17
55
|
init: async request => {
|
|
18
56
|
authPluginHelper.assertType(request.type, plugin)
|
|
19
57
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return { challenge }
|
|
58
|
+
return { challenge: base64.encode(randomBytes(32)) }
|
|
23
59
|
},
|
|
24
60
|
|
|
25
61
|
authenticate: async credential => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
throw new PluginMissconfigured('value')
|
|
30
|
-
}
|
|
31
|
-
const validateRecaptcha = context.entrypoint<ClientEntrypoint<RecpatchaResponse>>(MOD_RECAPTCHA)
|
|
32
|
-
const { value: result, outcome: status } = await validateRecaptcha.invoke<RecpatchaResponse, RecaptchaRequest>({
|
|
33
|
-
body: {
|
|
34
|
-
secret: config.value,
|
|
35
|
-
response: credential.credential
|
|
36
|
-
},
|
|
37
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' }
|
|
38
|
-
})
|
|
39
|
-
if (status !== EntrypointOutcome.Ok) {
|
|
40
|
-
throw new AuthPluginError('recaptcha:api')
|
|
41
|
-
}
|
|
42
|
-
if (!result.success) {
|
|
43
|
-
throw new AuthenFailed('recaptcha:' + (result['error-codes']?.join(',') ?? 'unknown'))
|
|
44
|
-
}
|
|
62
|
+
const [secret, policy] = await configured()
|
|
63
|
+
// Read before the policy is judged: a misconfigured record fails before Google is asked.
|
|
64
|
+
policy.minScore()
|
|
45
65
|
|
|
46
|
-
credential.
|
|
47
|
-
|
|
48
|
-
credential.type = AuthenticationType.ReCaptcha
|
|
66
|
+
const answer = await verifier().verify({ secret, response: credential.credential })
|
|
67
|
+
policy.assert(answer)
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
if (challenge != null) {
|
|
52
|
-
credential.challenge = previous
|
|
53
|
-
|
|
54
|
-
return { token: previous }
|
|
55
|
-
}
|
|
69
|
+
resetIdentity(credential)
|
|
56
70
|
|
|
71
|
+
// '' keeps the manager's own server-issued challenge on the token: that is its spend key.
|
|
57
72
|
return { token: '' }
|
|
58
73
|
}
|
|
59
74
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AllowanceResponse } from '@owlmeans/auth'
|
|
2
2
|
import type { AppConfig, AppContext, AuthModel } from '../types.js'
|
|
3
|
-
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
4
3
|
import type { RedisResource } from '@owlmeans/redis-resource'
|
|
5
4
|
import type { ResourceRecord } from '@owlmeans/resource'
|
|
6
5
|
import type { AuthChallengeReplayPolicy } from './consts.js'
|
|
@@ -14,19 +13,6 @@ export interface AuthPlugin extends Omit<AuthModel, "rely"> {
|
|
|
14
13
|
challengeReplayPolicy?: AuthChallengeReplayPolicy
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
export interface RecpatchaResponse {
|
|
18
|
-
success: boolean
|
|
19
|
-
challenge_ts: number
|
|
20
|
-
hostname: string
|
|
21
|
-
'error-codes'?: string[]
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface RecaptchaRequest extends AbstractRequest<{
|
|
25
|
-
secret: string
|
|
26
|
-
response: string
|
|
27
|
-
remoteip?: string
|
|
28
|
-
}> { }
|
|
29
|
-
|
|
30
16
|
export interface RelyRecord extends ResourceRecord, AllowanceResponse {
|
|
31
17
|
}
|
|
32
18
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AUTH_SCOPE, AuthenticationType, AuthRole, GUEST_ID, RECAPTCHA_GUARD } from '@owlmeans/auth'
|
|
2
|
+
import type { Auth } from '@owlmeans/auth'
|
|
3
|
+
import { extractAuthToken } from '@owlmeans/auth-common/utils'
|
|
4
|
+
import { createService } from '@owlmeans/context'
|
|
5
|
+
import type { AbstractRequest, AbstractResponse, GuardService } from '@owlmeans/entrypoint'
|
|
6
|
+
import type { Config, Context } from '../types.local.js'
|
|
7
|
+
import { reCaptchaTokenOf } from './token.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The guard of a route a reCAPTCHA-proven guest may call once (`RECAPTCHA_GUARD`):
|
|
11
|
+
* `Authorization: RE-CAPTCHA <token>`, the token the auth manager's reCAPTCHA plugin issued.
|
|
12
|
+
*
|
|
13
|
+
* Every check of `ReCaptchaTokenHelper.inspect`, then the spend — a token admits one request. The
|
|
14
|
+
* request's `auth` is a guest (`GUEST_ID`, role Guest, `[AUTH_SCOPE]`, type ReCaptcha) with an empty
|
|
15
|
+
* `token`: whatever reads an authorization downstream may log it. A refused token is no credential at
|
|
16
|
+
* all, so the request is unauthenticated (401).
|
|
17
|
+
*/
|
|
18
|
+
export const makeReCaptchaGuard = (alias: string = RECAPTCHA_GUARD): GuardService => {
|
|
19
|
+
const service: GuardService = createService<GuardService>(alias, {
|
|
20
|
+
match: async req => req != null && extractAuthToken(req, AuthenticationType.ReCaptcha) != null,
|
|
21
|
+
|
|
22
|
+
handle: async <T>(req: AbstractRequest, res: AbstractResponse<Auth>) => {
|
|
23
|
+
const context = service.assertCtx<Config, Context>()
|
|
24
|
+
const tokens = reCaptchaTokenOf(context)
|
|
25
|
+
|
|
26
|
+
const guest = await tokens.inspect(req)
|
|
27
|
+
if (guest == null || !await tokens.spend(guest)) {
|
|
28
|
+
return false as T
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const auth: Auth = {
|
|
32
|
+
token: '',
|
|
33
|
+
type: AuthenticationType.ReCaptcha,
|
|
34
|
+
role: AuthRole.Guest,
|
|
35
|
+
userId: GUEST_ID,
|
|
36
|
+
scopes: [AUTH_SCOPE],
|
|
37
|
+
isUser: false,
|
|
38
|
+
source: context.cfg.service,
|
|
39
|
+
createdAt: new Date(),
|
|
40
|
+
expiresAt: guest.expiresAt,
|
|
41
|
+
}
|
|
42
|
+
res.resolve(auth)
|
|
43
|
+
|
|
44
|
+
return true as T
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/** Server-side guard: it verifies what arrives, it never produces a credential. */
|
|
48
|
+
authenticated: async () => null,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return service
|
|
52
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { AuthenticationType, AuthRole, AuthUnavailable, GUEST_ID } from '@owlmeans/auth'
|
|
2
|
+
import type { AuthCredentials } from '@owlmeans/auth'
|
|
3
|
+
import { extractAuthToken, trust } from '@owlmeans/auth-common/utils'
|
|
4
|
+
import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
|
|
5
|
+
import type { EnvelopeModel } from '@owlmeans/basic-envelope'
|
|
6
|
+
import { TRUSTED } from '@owlmeans/config'
|
|
7
|
+
import { memoHelper } from '@owlmeans/context'
|
|
8
|
+
import { logger } from '@owlmeans/log'
|
|
9
|
+
import { RecordExists } from '@owlmeans/resource'
|
|
10
|
+
import type { Resource } from '@owlmeans/resource'
|
|
11
|
+
import { AUTH_CACHE, AUTH_SRV_KEY, AUTHEN_TIMEFRAME } from '../consts.js'
|
|
12
|
+
import type { AuthSpent } from '../types.js'
|
|
13
|
+
import type { Config, Context } from '../types.local.js'
|
|
14
|
+
import type { ReCaptchaCarrier, ReCaptchaGuest, ReCaptchaTokenHelper } from './types.js'
|
|
15
|
+
|
|
16
|
+
const log = logger('server-auth:re-captcha')
|
|
17
|
+
|
|
18
|
+
export const makeReCaptchaTokenHelper = (context: Context): ReCaptchaTokenHelper => {
|
|
19
|
+
const refuse = (reason: string): null => {
|
|
20
|
+
log.debug('reCAPTCHA token refused', { reason }, { event: 'auth.refused' })
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const verify = async (token: string): Promise<ReCaptchaGuest | null> => {
|
|
25
|
+
let envelope: EnvelopeModel<AuthCredentials>
|
|
26
|
+
try {
|
|
27
|
+
envelope = makeEnvelopeModel<AuthCredentials>(token, EnvelopeKind.Token)
|
|
28
|
+
} catch {
|
|
29
|
+
return refuse('token')
|
|
30
|
+
}
|
|
31
|
+
if (envelope.type() !== AuthenticationType.ReCaptcha) {
|
|
32
|
+
return refuse('type')
|
|
33
|
+
}
|
|
34
|
+
// A guest token lives no longer than its spend record does, so it can never be spent twice.
|
|
35
|
+
const ttl = envelope.envelope.ttl
|
|
36
|
+
if (ttl == null || ttl > AUTHEN_TIMEFRAME) {
|
|
37
|
+
return refuse('ttl')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const signer = await trust<Config, Context>(context, TRUSTED, AUTH_SRV_KEY)
|
|
41
|
+
// Signature and expiry: an envelope past `dt + ttl` does not verify.
|
|
42
|
+
if (!await envelope.verify(signer.key)) {
|
|
43
|
+
return refuse('signature')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const credential = envelope.message<AuthCredentials>()
|
|
47
|
+
if (credential == null || typeof credential !== 'object') {
|
|
48
|
+
return refuse('token')
|
|
49
|
+
}
|
|
50
|
+
if (credential.credential !== signer.user.id) {
|
|
51
|
+
return refuse('issuer')
|
|
52
|
+
}
|
|
53
|
+
if (credential.type !== AuthenticationType.ReCaptcha || credential.role !== AuthRole.Guest
|
|
54
|
+
|| credential.userId !== GUEST_ID) {
|
|
55
|
+
return refuse('guest')
|
|
56
|
+
}
|
|
57
|
+
if (typeof credential.challenge !== 'string' || credential.challenge === '') {
|
|
58
|
+
return refuse('challenge')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { credential, challenge: credential.challenge, expiresAt: new Date(envelope.envelope.dt + ttl) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inspect = async (req: ReCaptchaCarrier): Promise<ReCaptchaGuest | null> => {
|
|
65
|
+
const token = extractAuthToken(req, AuthenticationType.ReCaptcha)
|
|
66
|
+
|
|
67
|
+
return token == null || token === '' ? null : await verify(token)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const spend = async (guest: ReCaptchaGuest): Promise<boolean> => {
|
|
71
|
+
try {
|
|
72
|
+
await context.resource<Resource<AuthSpent>>(AUTH_CACHE)
|
|
73
|
+
.create({ id: guest.challenge }, { ttl: AUTHEN_TIMEFRAME / 1000 })
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof RecordExists || (error as { type?: string } | null)?.type === RecordExists.typeName) {
|
|
76
|
+
log.debug('reCAPTCHA token refused', { reason: 'spent' }, { event: 'auth.refused' })
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
throw new AuthUnavailable('auth-cache')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { inspect, verify, spend }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The reCAPTCHA token checks of a context — built once per context. */
|
|
89
|
+
export const reCaptchaTokenOf = memoHelper.oncePer(makeReCaptchaTokenHelper)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AuthCredentials } from '@owlmeans/auth'
|
|
2
|
+
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
|
|
4
|
+
/** A reCAPTCHA guest token that passed every check but the spend. */
|
|
5
|
+
export interface ReCaptchaGuest {
|
|
6
|
+
/**
|
|
7
|
+
* The credential the auth manager signed: `GUEST_ID`, role Guest, type ReCaptcha, stamped with
|
|
8
|
+
* the auth service record's id. It names nobody.
|
|
9
|
+
*/
|
|
10
|
+
credential: AuthCredentials
|
|
11
|
+
/** The server-issued challenge the token carries — the key it is spent under in `AUTH_CACHE`. */
|
|
12
|
+
challenge: string
|
|
13
|
+
/** When the token stops being accepted. */
|
|
14
|
+
expiresAt: Date
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Anything that carries request headers: an entrypoint request, or a raw Fastify request. */
|
|
18
|
+
export interface ReCaptchaCarrier extends Partial<Pick<AbstractRequest, 'headers'>> {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The checks of a reCAPTCHA guest token (`Authorization: RE-CAPTCHA <token>`), bound to the context
|
|
22
|
+
* whose `TRUSTED` record `AUTH_SRV_KEY` signed it and whose `AUTH_CACHE` spends it.
|
|
23
|
+
*/
|
|
24
|
+
export interface ReCaptchaTokenHelper {
|
|
25
|
+
/**
|
|
26
|
+
* The guest a request carries, checked WITHOUT spending: envelope type, signature by the auth
|
|
27
|
+
* service key, TTL (at most `AUTHEN_TIMEFRAME`), the auth record's stamp, type ReCaptcha, role
|
|
28
|
+
* Guest, `GUEST_ID`, a server-issued challenge. Made for a pre-parse check (before a body is read);
|
|
29
|
+
* the route itself still needs the guard, which spends.
|
|
30
|
+
*
|
|
31
|
+
* @returns `null` for a request without such a header or with a token that fails a check.
|
|
32
|
+
* @throws {SyntaxError} when the context has no `AUTH_SRV_KEY` trusted record (a configuration fault).
|
|
33
|
+
*/
|
|
34
|
+
inspect: (req: ReCaptchaCarrier) => Promise<ReCaptchaGuest | null>
|
|
35
|
+
/** {@link inspect} for a bare token (the value after `RE-CAPTCHA `). */
|
|
36
|
+
verify: (token: string) => Promise<ReCaptchaGuest | null>
|
|
37
|
+
/**
|
|
38
|
+
* Spend the token: an atomic create-once of its challenge in `AUTH_CACHE` (Redis `SET NX` in a
|
|
39
|
+
* backend context), under the same key a bearer exchange and the IAM finalizer claim.
|
|
40
|
+
*
|
|
41
|
+
* @returns `false` when the token was spent before.
|
|
42
|
+
* @throws {AuthUnavailable} when the cache cannot answer.
|
|
43
|
+
*/
|
|
44
|
+
spend: (guest: ReCaptchaGuest) => Promise<boolean>
|
|
45
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { AUTH_CACHE, AUTH_SRV_KEY, AUTHEN_TIMEFRAME, DEFAULT_ALIAS } from './con
|
|
|
4
4
|
import type { AuthServiceAppend, AuthService, AuthSpent } from './types.js'
|
|
5
5
|
import { assertContext, createService } from '@owlmeans/context'
|
|
6
6
|
import { EnvelopeKind, makeEnvelopeModel } from '@owlmeans/basic-envelope'
|
|
7
|
-
import { type Auth, type AuthCredentials, AuthenFailed, AuthorizationError, AuthroizationType, AuthUnavailable } from '@owlmeans/auth'
|
|
7
|
+
import { type Auth, type AuthCredentials, AuthenFailed, AuthenticationType, AuthorizationError, AuthroizationType, AuthUnavailable } from '@owlmeans/auth'
|
|
8
8
|
import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
|
|
9
9
|
import type { Resource } from '@owlmeans/resource'
|
|
10
10
|
import { createStaticResource } from '@owlmeans/static-resource'
|
|
@@ -164,7 +164,12 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
|
164
164
|
const credentials = envelope.message()
|
|
165
165
|
const msg = credentials.challenge
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// A reCAPTCHA token proves a solved challenge, never a person: it is spent by
|
|
168
|
+
// `makeReCaptchaGuard` on the one route that takes it and never becomes a session. Refused
|
|
169
|
+
// before the spend, so a token presented here stays usable where it belongs.
|
|
170
|
+
if (envelope.type() === AuthenticationType.ReCaptcha || credentials.type === AuthenticationType.ReCaptcha) {
|
|
171
|
+
throw refuse('guest', new AuthenFailed('guest'), credentials.userId)
|
|
172
|
+
}
|
|
168
173
|
|
|
169
174
|
try {
|
|
170
175
|
await cache(context).create({ id: msg }, { ttl: AUTHEN_TIMEFRAME / 1000 })
|