@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,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
|
@@ -1,25 +1,85 @@
|
|
|
1
|
-
import type { ServerContext
|
|
1
|
+
import type { ServerContext } from '@owlmeans/server-context'
|
|
2
2
|
import { TRUSTED } from '@owlmeans/config'
|
|
3
3
|
import { AUTH_CACHE, AUTH_SRV_KEY, AUTHEN_TIMEFRAME, DEFAULT_ALIAS } from './consts.js'
|
|
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
|
|
8
|
-
import { AuthenFailed, AuthorizationError, AuthroizationType } from '@owlmeans/auth'
|
|
7
|
+
import { type Auth, type AuthCredentials, AuthenFailed, AuthenticationType, AuthorizationError, AuthroizationType, AuthUnavailable } from '@owlmeans/auth'
|
|
9
8
|
import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
|
|
10
9
|
import type { Resource } from '@owlmeans/resource'
|
|
11
10
|
import { createStaticResource } from '@owlmeans/static-resource'
|
|
12
11
|
import { trust, extractAuthToken } from '@owlmeans/auth-common/utils'
|
|
12
|
+
import { ENTITY_RESOLVER, type EntityResolverService, TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
13
|
+
import { AUTH_IDENTITY_PROFILE, type IdentityProfile, type IdentityProfileResource } from '@owlmeans/server-auth-identity'
|
|
14
|
+
import { appendMemoryAuthSessionManager, AUTH_SESSION_MANAGER, AUTH_SESSION_TTL, type AuthSessionManager } from '@owlmeans/server-auth-session'
|
|
15
|
+
import { idHelper } from '@owlmeans/basic-ids'
|
|
16
|
+
import { logger } from '@owlmeans/log'
|
|
17
|
+
import type { Config, Context } from './types.local.js'
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
|
|
20
|
+
const log = logger('server-auth')
|
|
16
21
|
|
|
17
22
|
export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
23
|
+
/**
|
|
24
|
+
* A refused token exchange — the manager's token presented to open a session. Logged with the
|
|
25
|
+
* reason and the account id only; returns the error for the caller to throw.
|
|
26
|
+
*/
|
|
27
|
+
const refuse = (reason: string, error: AuthenFailed, userId?: string): AuthenFailed => {
|
|
28
|
+
const accountId = userId == null ? undefined
|
|
29
|
+
: userId.includes('@') ? `*@${userId.slice(userId.lastIndexOf('@') + 1)}` : userId
|
|
30
|
+
log.warn('Session exchange refused', { reason, ...(accountId != null ? { accountId } : {}) }, { event: 'auth.refused' })
|
|
31
|
+
return error
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
const location = `server-auth:${alias}`
|
|
19
35
|
|
|
20
36
|
const cache = (context: Context): Resource<AuthSpent> =>
|
|
21
37
|
context.resource<Resource<AuthSpent>>(AUTH_CACHE)
|
|
22
38
|
|
|
39
|
+
const sessions = (context: Context): AuthSessionManager | null =>
|
|
40
|
+
context.hasService(AUTH_SESSION_MANAGER)
|
|
41
|
+
? context.service<AuthSessionManager>(AUTH_SESSION_MANAGER)
|
|
42
|
+
: null
|
|
43
|
+
|
|
44
|
+
const entityIdFor = async (context: Context, entitySlug: string | undefined): Promise<string | null> => {
|
|
45
|
+
if (entitySlug == null || entitySlug === '') return null
|
|
46
|
+
if (!context.hasService(ENTITY_RESOLVER)) return entitySlug
|
|
47
|
+
const entity = await context.service<EntityResolverService>(ENTITY_RESOLVER).resolve(entitySlug)
|
|
48
|
+
return entity?.id ?? null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const refresh = async (context: Context, auth: Auth, version: number): Promise<Auth | null> => {
|
|
52
|
+
const entityId = await entityIdFor(context, auth.entitySlug)
|
|
53
|
+
if (entityId == null || auth.profileId == null || !context.hasResource(AUTH_IDENTITY_PROFILE)) return null
|
|
54
|
+
const profiles = context.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
55
|
+
let profile: IdentityProfile | null = null
|
|
56
|
+
try {
|
|
57
|
+
profile = await profiles.load({ entityId, profileId: auth.profileId })
|
|
58
|
+
} catch {
|
|
59
|
+
// A missing profile is a definitive authentication refusal; a failed profile store is an
|
|
60
|
+
// availability dependency and must not look like a revoked browser session.
|
|
61
|
+
throw new AuthUnavailable('identity-profile')
|
|
62
|
+
}
|
|
63
|
+
if (profile == null || (profile.expiresAt != null && new Date(profile.expiresAt).getTime() <= Date.now())) return null
|
|
64
|
+
return {
|
|
65
|
+
...auth,
|
|
66
|
+
userId: profile.userId ?? auth.userId,
|
|
67
|
+
role: profile.role,
|
|
68
|
+
scopes: profile.scopes ?? [],
|
|
69
|
+
...(profile.permissions != null ? { permissions: profile.permissions } : {}),
|
|
70
|
+
authorizationVersion: version,
|
|
71
|
+
createdAt: new Date()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sign = async (context: Context, auth: Auth): Promise<string> => {
|
|
76
|
+
const trusted = await trust<Config, Context>(context, TRUSTED, context.cfg.alias ?? context.cfg.service)
|
|
77
|
+
const ttl = auth.expiresAt == null ? AUTH_SESSION_TTL : Math.max(0, new Date(auth.expiresAt).getTime() - Date.now())
|
|
78
|
+
const authorization = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
|
|
79
|
+
.send(auth, ttl).sign(trusted.key, EnvelopeKind.Token)
|
|
80
|
+
return `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${authorization}`
|
|
81
|
+
}
|
|
82
|
+
|
|
23
83
|
const service: AuthService = createService<AuthService>(alias, {
|
|
24
84
|
match: async req => extractAuthToken(req, AuthroizationType.Ed25519BasicToken) != null,
|
|
25
85
|
|
|
@@ -37,7 +97,29 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
|
37
97
|
return false as T
|
|
38
98
|
}
|
|
39
99
|
|
|
40
|
-
|
|
100
|
+
let auth = envelope.message()
|
|
101
|
+
const manager = sessions(context)
|
|
102
|
+
if (manager != null) {
|
|
103
|
+
if (auth.sessionId == null) return false as T
|
|
104
|
+
let decision
|
|
105
|
+
try {
|
|
106
|
+
decision = await manager.inspect(auth.sessionId)
|
|
107
|
+
} catch {
|
|
108
|
+
// Registry availability is a security dependency. A guard cannot authenticate through it.
|
|
109
|
+
throw new AuthUnavailable('session-registry')
|
|
110
|
+
}
|
|
111
|
+
if (decision.state !== 'active' && decision.state !== 'refresh') return false as T
|
|
112
|
+
if (decision.state === 'refresh') {
|
|
113
|
+
const refreshed = await refresh(context, auth, decision.version)
|
|
114
|
+
if (refreshed == null) return false as T
|
|
115
|
+
auth = refreshed
|
|
116
|
+
const replacement = await sign(context, auth)
|
|
117
|
+
res.responseProvider?.header(TOKEN_UPDATE, replacement)
|
|
118
|
+
req.headers = { ...req.headers, authorization: replacement }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
res.resolve(auth)
|
|
41
123
|
|
|
42
124
|
return true as T
|
|
43
125
|
},
|
|
@@ -51,8 +133,19 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
|
51
133
|
if (!await envelope.verify(trusted.key)) {
|
|
52
134
|
throw new AuthorizationError('unpack')
|
|
53
135
|
}
|
|
54
|
-
|
|
55
|
-
|
|
136
|
+
const auth = envelope.message()
|
|
137
|
+
const manager = sessions(context)
|
|
138
|
+
if (manager != null) {
|
|
139
|
+
if (auth.sessionId == null) throw new AuthorizationError('session')
|
|
140
|
+
let decision
|
|
141
|
+
try {
|
|
142
|
+
decision = await manager.inspect(auth.sessionId)
|
|
143
|
+
} catch {
|
|
144
|
+
throw new AuthUnavailable('session-registry')
|
|
145
|
+
}
|
|
146
|
+
if (decision.state !== 'active') throw new AuthorizationError('session')
|
|
147
|
+
}
|
|
148
|
+
return auth
|
|
56
149
|
},
|
|
57
150
|
|
|
58
151
|
authenticated: async () => {
|
|
@@ -65,25 +158,32 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
|
65
158
|
|
|
66
159
|
const authService = await trust<Config, Context>(context, TRUSTED, AUTH_SRV_KEY)
|
|
67
160
|
if (!await envelope.verify(authService.key)) {
|
|
68
|
-
throw new AuthenFailed()
|
|
161
|
+
throw refuse('signature', new AuthenFailed())
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
const credentials = envelope.message()
|
|
72
165
|
const msg = credentials.challenge
|
|
73
166
|
|
|
74
|
-
//
|
|
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
|
+
}
|
|
75
173
|
|
|
76
174
|
try {
|
|
77
175
|
await cache(context).create({ id: msg }, { ttl: AUTHEN_TIMEFRAME / 1000 })
|
|
78
176
|
} catch {
|
|
79
|
-
throw new AuthenFailed()
|
|
177
|
+
throw refuse('replay', new AuthenFailed(), credentials.userId)
|
|
80
178
|
}
|
|
81
179
|
|
|
82
180
|
if (credentials.credential !== authService.user.id) {
|
|
83
|
-
throw new AuthenFailed()
|
|
181
|
+
throw refuse('issuer', new AuthenFailed(), credentials.userId)
|
|
84
182
|
}
|
|
85
183
|
|
|
86
184
|
// @TODO Move to a plugin
|
|
185
|
+
const issuedAt = Date.now()
|
|
186
|
+
const expiresAt = new Date(issuedAt + AUTH_SESSION_TTL)
|
|
87
187
|
const auth: Auth = {
|
|
88
188
|
token: credentials.challenge,
|
|
89
189
|
userId: credentials.userId,
|
|
@@ -92,16 +192,26 @@ export const makeAuthService = (alias: string = DEFAULT_ALIAS): AuthService => {
|
|
|
92
192
|
type: AuthroizationType.Ed25519BasicToken,
|
|
93
193
|
source: context.cfg.service,
|
|
94
194
|
profileId: credentials.profileId,
|
|
95
|
-
|
|
195
|
+
entitySlug: credentials.entitySlug,
|
|
96
196
|
isUser: true,
|
|
97
|
-
createdAt: new Date()
|
|
197
|
+
createdAt: new Date(issuedAt),
|
|
198
|
+
expiresAt,
|
|
199
|
+
sessionId: idHelper.uuid(),
|
|
200
|
+
authorizationVersion: 1
|
|
201
|
+
}
|
|
202
|
+
const manager = sessions(context)
|
|
203
|
+
if (manager != null) {
|
|
204
|
+
const entityId = await entityIdFor(context, auth.entitySlug)
|
|
205
|
+
if (entityId == null || auth.profileId == null) throw refuse('session', new AuthenFailed('session'), auth.userId)
|
|
206
|
+
const decision = await manager.register({
|
|
207
|
+
id: auth.sessionId!, kind: 'bearer', entityId, profileId: auth.profileId,
|
|
208
|
+
issuedAt, expiresAt: expiresAt.getTime()
|
|
209
|
+
})
|
|
210
|
+
if (decision.state !== 'active') throw refuse('session', new AuthenFailed('session'), auth.userId)
|
|
211
|
+
auth.authorizationVersion = decision.version
|
|
98
212
|
}
|
|
99
213
|
|
|
100
|
-
|
|
101
|
-
const authorization = await makeEnvelopeModel<Auth>(AuthroizationType.Ed25519BasicToken)
|
|
102
|
-
.send(auth, null).sign(trusted.key, EnvelopeKind.Token)
|
|
103
|
-
|
|
104
|
-
return { token: `${AuthroizationType.Ed25519BasicToken.toUpperCase()} ${authorization}` }
|
|
214
|
+
return { token: await sign(context, auth) }
|
|
105
215
|
}
|
|
106
216
|
})
|
|
107
217
|
|
|
@@ -116,6 +226,10 @@ export const appendAuthService = <C extends Config, T extends ServerContext<C>>(
|
|
|
116
226
|
ctx.registerResource(createStaticResource(AUTH_CACHE))
|
|
117
227
|
}
|
|
118
228
|
|
|
229
|
+
// Every standalone server gets an expiring process-local registry. Scaled deployments replace
|
|
230
|
+
// it with appendRedisAuthSessionManager before calling this helper.
|
|
231
|
+
appendMemoryAuthSessionManager(ctx)
|
|
232
|
+
|
|
119
233
|
const service = makeAuthService(alias)
|
|
120
234
|
const context = ctx as T & AuthServiceAppend
|
|
121
235
|
|
package/tests/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppType,
|
|
1
|
+
import { AppType, makeBasicContext } from '@owlmeans/context'
|
|
2
2
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
3
3
|
import { createStaticResource } from '@owlmeans/static-resource'
|
|
4
4
|
import { makeMemoryTrustedResource, makeFixtureKeyPair } from '@owlmeans/test-auth'
|
|
@@ -25,6 +25,8 @@ export const makeTestContext = () => {
|
|
|
25
25
|
id: authServiceKP.exportAddress(),
|
|
26
26
|
name: AUTH_SRV_KEY,
|
|
27
27
|
credential: authServiceKP.exportPublic(),
|
|
28
|
+
// Local, deterministic test-only key: the manager signs its synthetic exchange envelope.
|
|
29
|
+
secret: authServiceKP.export(),
|
|
28
30
|
scopes: ['*'],
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -39,7 +41,6 @@ export const makeTestContext = () => {
|
|
|
39
41
|
const cfg: BasicConfig = {
|
|
40
42
|
ready: false,
|
|
41
43
|
service: SERVICE_NAME,
|
|
42
|
-
layer: Layer.Service,
|
|
43
44
|
type: AppType.Backend,
|
|
44
45
|
services: {},
|
|
45
46
|
}
|