@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.
Files changed (85) hide show
  1. package/README.md +36 -4
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-auth/SKILL.md +64 -4
  4. package/build/index.d.ts +1 -0
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +1 -0
  7. package/build/index.js.map +1 -1
  8. package/build/manager/entrypoints.d.ts.map +1 -1
  9. package/build/manager/entrypoints.js +1 -9
  10. package/build/manager/entrypoints.js.map +1 -1
  11. package/build/manager/plugins/export.d.ts +4 -0
  12. package/build/manager/plugins/export.d.ts.map +1 -1
  13. package/build/manager/plugins/export.js +3 -0
  14. package/build/manager/plugins/export.js.map +1 -1
  15. package/build/manager/plugins/re-captcha/consts.d.ts +7 -0
  16. package/build/manager/plugins/re-captcha/consts.d.ts.map +1 -0
  17. package/build/manager/plugins/re-captcha/consts.js +7 -0
  18. package/build/manager/plugins/re-captcha/consts.js.map +1 -0
  19. package/build/manager/plugins/re-captcha/consts.local.d.ts +7 -0
  20. package/build/manager/plugins/re-captcha/consts.local.d.ts.map +1 -0
  21. package/build/manager/plugins/re-captcha/consts.local.js +9 -0
  22. package/build/manager/plugins/re-captcha/consts.local.js.map +1 -0
  23. package/build/manager/plugins/re-captcha/policy.d.ts +3 -0
  24. package/build/manager/plugins/re-captcha/policy.d.ts.map +1 -0
  25. package/build/manager/plugins/re-captcha/policy.js +44 -0
  26. package/build/manager/plugins/re-captcha/policy.js.map +1 -0
  27. package/build/manager/plugins/re-captcha/types.d.ts +79 -0
  28. package/build/manager/plugins/re-captcha/types.d.ts.map +1 -0
  29. package/build/manager/plugins/re-captcha/types.js +2 -0
  30. package/build/manager/plugins/re-captcha/types.js.map +1 -0
  31. package/build/manager/plugins/re-captcha/verifier.d.ts +12 -0
  32. package/build/manager/plugins/re-captcha/verifier.d.ts.map +1 -0
  33. package/build/manager/plugins/re-captcha/verifier.js +62 -0
  34. package/build/manager/plugins/re-captcha/verifier.js.map +1 -0
  35. package/build/manager/plugins/re-captcha.d.ts +9 -0
  36. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  37. package/build/manager/plugins/re-captcha.js +46 -33
  38. package/build/manager/plugins/re-captcha.js.map +1 -1
  39. package/build/manager/plugins/types.d.ts +0 -13
  40. package/build/manager/plugins/types.d.ts.map +1 -1
  41. package/build/re-captcha/guard.d.ts +12 -0
  42. package/build/re-captcha/guard.d.ts.map +1 -0
  43. package/build/re-captcha/guard.js +43 -0
  44. package/build/re-captcha/guard.js.map +1 -0
  45. package/build/re-captcha/index.d.ts +4 -0
  46. package/build/re-captcha/index.d.ts.map +1 -0
  47. package/build/re-captcha/index.js +3 -0
  48. package/build/re-captcha/index.js.map +1 -0
  49. package/build/re-captcha/token.d.ts +6 -0
  50. package/build/re-captcha/token.d.ts.map +1 -0
  51. package/build/re-captcha/token.js +74 -0
  52. package/build/re-captcha/token.js.map +1 -0
  53. package/build/re-captcha/types.d.ts +44 -0
  54. package/build/re-captcha/types.d.ts.map +1 -0
  55. package/build/re-captcha/types.js +2 -0
  56. package/build/re-captcha/types.js.map +1 -0
  57. package/build/service.d.ts.map +1 -1
  58. package/build/service.js +7 -2
  59. package/build/service.js.map +1 -1
  60. package/package.json +20 -20
  61. package/src/index.ts +1 -1
  62. package/src/manager/README.md +1 -1
  63. package/src/manager/entrypoints.ts +1 -10
  64. package/src/manager/plugins/export.ts +4 -0
  65. package/src/manager/plugins/re-captcha/consts.local.ts +8 -0
  66. package/src/manager/plugins/re-captcha/consts.ts +8 -0
  67. package/src/manager/plugins/re-captcha/policy.ts +56 -0
  68. package/src/manager/plugins/re-captcha/types.ts +85 -0
  69. package/src/manager/plugins/re-captcha/verifier.ts +74 -0
  70. package/src/manager/plugins/re-captcha.ts +54 -39
  71. package/src/manager/plugins/types.ts +0 -14
  72. package/src/re-captcha/guard.ts +52 -0
  73. package/src/re-captcha/index.ts +3 -0
  74. package/src/re-captcha/token.ts +89 -0
  75. package/src/re-captcha/types.ts +45 -0
  76. package/src/service.ts +7 -2
  77. package/tests/re-captcha.spec.ts +298 -0
  78. package/build/manager/actions/manager.d.ts +0 -18
  79. package/build/manager/actions/manager.d.ts.map +0 -1
  80. package/build/manager/actions/manager.js +0 -11
  81. package/build/manager/actions/manager.js.map +0 -1
  82. package/build/manager/plugins/replay-policy.d.ts +0 -8
  83. package/build/manager/plugins/replay-policy.d.ts.map +0 -1
  84. package/build/manager/plugins/replay-policy.js +0 -9
  85. 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 { AuthenFailed, AuthenticationType, AuthPluginError, MOD_RECAPTCHA, AUTH_SCOPE, AuthRole } from '@owlmeans/auth'
2
- import type { AppContext, AppConfig } from '../types.js'
3
- import type { AuthPlugin, RecaptchaRequest, RecpatchaResponse } from './types.js'
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
- const challenge = base64.encode(randomBytes(32))
21
-
22
- return { challenge }
58
+ return { challenge: base64.encode(randomBytes(32)) }
23
59
  },
24
60
 
25
61
  authenticate: async credential => {
26
- const cfg = context.getConfigResource<PluginConfig>(PLUGINS)
27
- const config = await cfg.get(MOD_RECAPTCHA)
28
- if (config.value == null) {
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.scopes = [AUTH_SCOPE]
47
- credential.role = AuthRole.Guest
48
- credential.type = AuthenticationType.ReCaptcha
66
+ const answer = await verifier().verify({ secret, response: credential.credential })
67
+ policy.assert(answer)
49
68
 
50
- const [previous, challenge] = credential.challenge.split(':') as [string, string | undefined]
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,3 @@
1
+ export type * from './types.js'
2
+ export * from './token.js'
3
+ export * from './guard.js'
@@ -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
- // @TODO This operation is not atomic in case of redis store usage and scling
167
+ // A reCAPTCHA token proves a solved challenge, never a person: it is spent by
168
+ // `makeReCaptchaGuard` on the one route that takes it and never becomes a session. Refused
169
+ // before the spend, so a token presented here stays usable where it belongs.
170
+ if (envelope.type() === AuthenticationType.ReCaptcha || credentials.type === AuthenticationType.ReCaptcha) {
171
+ throw refuse('guest', new AuthenFailed('guest'), credentials.userId)
172
+ }
168
173
 
169
174
  try {
170
175
  await cache(context).create({ id: msg }, { ttl: AUTHEN_TIMEFRAME / 1000 })