@toa.io/extensions.exposition 0.20.0-dev.9 → 0.21.0-alpha.0
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/components/context.toa.yaml +15 -0
- package/components/identity.bans/manifest.toa.yaml +18 -0
- package/components/identity.basic/events/principal.js +9 -0
- package/components/identity.basic/manifest.toa.yaml +50 -0
- package/components/identity.basic/source/authenticate.ts +29 -0
- package/components/identity.basic/source/create.ts +19 -0
- package/components/identity.basic/source/transit.ts +64 -0
- package/components/identity.basic/source/types.ts +42 -0
- package/components/identity.basic/tsconfig.json +9 -0
- package/components/identity.roles/manifest.toa.yaml +31 -0
- package/components/identity.roles/source/list.ts +7 -0
- package/components/identity.roles/source/principal.ts +20 -0
- package/components/identity.roles/tsconfig.json +9 -0
- package/components/identity.tokens/manifest.toa.yaml +39 -0
- package/components/identity.tokens/receivers/identity.bans.updated.js +3 -0
- package/components/identity.tokens/source/authenticate.test.ts +56 -0
- package/components/identity.tokens/source/authenticate.ts +38 -0
- package/components/identity.tokens/source/decrypt.test.ts +59 -0
- package/components/identity.tokens/source/decrypt.ts +25 -0
- package/components/identity.tokens/source/encrypt.test.ts +35 -0
- package/components/identity.tokens/source/encrypt.ts +25 -0
- package/components/identity.tokens/source/revoke.ts +5 -0
- package/components/identity.tokens/source/types.ts +48 -0
- package/components/identity.tokens/tsconfig.json +9 -0
- package/cucumber.js +9 -0
- package/documentation/.assets/ia3-dark.jpg +0 -0
- package/documentation/.assets/ia3-light.jpg +0 -0
- package/documentation/.assets/overview-dark.jpg +0 -0
- package/documentation/.assets/overview-light.jpg +0 -0
- package/documentation/.assets/role-scopes-dark.jpg +0 -0
- package/documentation/.assets/role-scopes-light.jpg +0 -0
- package/documentation/.assets/rtd-dark.jpg +0 -0
- package/documentation/.assets/rtd-light.jpg +0 -0
- package/documentation/access.md +256 -0
- package/documentation/components.md +276 -0
- package/documentation/identity.md +156 -0
- package/documentation/notes/sse.md +71 -0
- package/documentation/protocol.md +18 -0
- package/documentation/query.md +226 -0
- package/documentation/tree.md +169 -0
- package/features/access.feature +448 -0
- package/features/annotation.feature +30 -0
- package/features/body.feature +45 -0
- package/features/directives.feature +56 -0
- package/features/dynamic.feature +99 -0
- package/features/errors.feature +193 -0
- package/features/identity.basic.feature +276 -0
- package/features/identity.feature +61 -0
- package/features/identity.roles.feature +51 -0
- package/features/identity.tokens.feature +119 -0
- package/features/queries.feature +214 -0
- package/features/routes.feature +49 -0
- package/features/steps/Common.ts +10 -0
- package/features/steps/Components.ts +43 -0
- package/features/steps/Database.ts +58 -0
- package/features/steps/Gateway.ts +113 -0
- package/features/steps/HTTP.ts +71 -0
- package/features/steps/Parameters.ts +12 -0
- package/features/steps/Workspace.ts +40 -0
- package/features/steps/components/echo/manifest.toa.yaml +9 -0
- package/features/steps/components/echo/operations/affect.js +7 -0
- package/features/steps/components/echo/operations/compute.js +7 -0
- package/features/steps/components/greeter/manifest.toa.yaml +5 -0
- package/features/steps/components/greeter/operations/greet.js +7 -0
- package/features/steps/components/pots/manifest.toa.yaml +20 -0
- package/features/steps/components/sequences/manifest.toa.yaml +10 -0
- package/features/steps/components/sequences/operations/numbers.js +7 -0
- package/features/steps/components/sequences/operations/tokens.js +16 -0
- package/features/steps/components/users/manifest.toa.yaml +11 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/streams.feature +26 -0
- package/package.json +34 -17
- package/readme.md +183 -0
- package/schemas/annotation.cos.yaml +5 -0
- package/schemas/directive.cos.yaml +3 -0
- package/schemas/method.cos.yaml +8 -0
- package/schemas/node.cos.yaml +5 -0
- package/schemas/query.cos.yaml +17 -0
- package/schemas/querystring.cos.yaml +5 -0
- package/schemas/range.cos.yaml +2 -0
- package/schemas/route.cos.yaml +2 -0
- package/source/Annotation.ts +7 -0
- package/source/Branch.ts +8 -0
- package/source/Composition.ts +57 -0
- package/source/Context.ts +6 -0
- package/source/Directive.test.ts +91 -0
- package/source/Directive.ts +120 -0
- package/source/Endpoint.ts +59 -0
- package/source/Factory.ts +51 -0
- package/source/Gateway.ts +93 -0
- package/source/HTTP/Server.fixtures.ts +45 -0
- package/source/HTTP/Server.test.ts +221 -0
- package/source/HTTP/Server.ts +135 -0
- package/source/HTTP/exceptions.ts +77 -0
- package/source/HTTP/formats/index.ts +19 -0
- package/source/HTTP/formats/json.ts +13 -0
- package/source/HTTP/formats/msgpack.ts +10 -0
- package/source/HTTP/formats/text.ts +9 -0
- package/source/HTTP/formats/yaml.ts +14 -0
- package/source/HTTP/index.ts +3 -0
- package/source/HTTP/messages.test.ts +116 -0
- package/source/HTTP/messages.ts +89 -0
- package/source/Mapping.ts +51 -0
- package/source/Query.test.ts +37 -0
- package/source/Query.ts +105 -0
- package/source/RTD/Context.ts +16 -0
- package/source/RTD/Directives.ts +9 -0
- package/source/RTD/Endpoint.ts +11 -0
- package/source/RTD/Match.ts +16 -0
- package/source/RTD/Method.ts +24 -0
- package/source/RTD/Node.ts +85 -0
- package/source/RTD/Route.ts +59 -0
- package/source/RTD/Tree.ts +54 -0
- package/source/RTD/factory.ts +47 -0
- package/source/RTD/index.ts +8 -0
- package/source/RTD/segment.test.ts +32 -0
- package/source/RTD/segment.ts +25 -0
- package/source/RTD/syntax/index.ts +2 -0
- package/source/RTD/syntax/parse.test.ts +188 -0
- package/source/RTD/syntax/parse.ts +153 -0
- package/source/RTD/syntax/types.ts +48 -0
- package/source/Remotes.test.ts +42 -0
- package/source/Remotes.ts +22 -0
- package/source/Tenant.ts +38 -0
- package/source/deployment.ts +49 -0
- package/source/directives/auth/Anonymous.ts +14 -0
- package/source/directives/auth/Echo.ts +12 -0
- package/source/directives/auth/Family.ts +145 -0
- package/source/directives/auth/Id.ts +19 -0
- package/source/directives/auth/Incept.ts +42 -0
- package/source/directives/auth/Role.test.ts +62 -0
- package/source/directives/auth/Role.ts +56 -0
- package/source/directives/auth/Rule.ts +28 -0
- package/source/directives/auth/Scheme.ts +26 -0
- package/source/directives/auth/index.ts +3 -0
- package/source/directives/auth/schemes.ts +8 -0
- package/source/directives/auth/split.ts +15 -0
- package/source/directives/auth/types.ts +37 -0
- package/source/directives/dev/Family.ts +36 -0
- package/source/directives/dev/Stub.ts +14 -0
- package/source/directives/dev/Throw.ts +14 -0
- package/source/directives/dev/index.ts +3 -0
- package/source/directives/dev/types.ts +5 -0
- package/source/directives/index.ts +5 -0
- package/source/discovery.ts +1 -0
- package/source/exceptions.ts +17 -0
- package/source/index.test.ts +9 -0
- package/source/index.ts +6 -0
- package/source/manifest.test.ts +59 -0
- package/source/manifest.ts +48 -0
- package/source/root.ts +38 -0
- package/source/schemas.ts +9 -0
- package/tsconfig.json +12 -0
- package/src/.manifest/index.js +0 -7
- package/src/.manifest/normalize.js +0 -58
- package/src/.manifest/schema.yaml +0 -71
- package/src/.manifest/validate.js +0 -17
- package/src/constants.js +0 -3
- package/src/deployment.js +0 -23
- package/src/exposition.js +0 -68
- package/src/factory.js +0 -76
- package/src/index.js +0 -9
- package/src/manifest.js +0 -12
- package/src/query/criteria.js +0 -55
- package/src/query/enum.js +0 -35
- package/src/query/index.js +0 -17
- package/src/query/query.js +0 -60
- package/src/query/range.js +0 -28
- package/src/query/sort.js +0 -19
- package/src/remote.js +0 -88
- package/src/server.js +0 -83
- package/src/tenant.js +0 -29
- package/src/translate/etag.js +0 -14
- package/src/translate/index.js +0 -7
- package/src/translate/request.js +0 -68
- package/src/translate/response.js +0 -62
- package/src/tree.js +0 -109
- package/test/manifest.normalize.fixtures.js +0 -37
- package/test/manifest.normalize.test.js +0 -37
- package/test/manifest.validate.test.js +0 -40
- package/test/query.range.test.js +0 -18
- package/test/tree.fixtures.js +0 -21
- package/test/tree.test.js +0 -44
- package/types/annotations.d.ts +0 -10
- package/types/declarations.d.ts +0 -31
- package/types/exposition.d.ts +0 -13
- package/types/http.d.ts +0 -13
- package/types/query.d.ts +0 -16
- package/types/remote.d.ts +0 -19
- package/types/server.d.ts +0 -13
- package/types/tree.d.ts +0 -33
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Dependency, type Service } from '@toa.io/operations'
|
|
2
|
+
import { encode } from '@toa.io/generic'
|
|
3
|
+
import { type Annotation } from './Annotation'
|
|
4
|
+
import * as schemas from './schemas'
|
|
5
|
+
import { shortcuts } from './Directive'
|
|
6
|
+
import { components } from './Composition'
|
|
7
|
+
import { parse } from './RTD/syntax'
|
|
8
|
+
|
|
9
|
+
export function deployment (_: unknown, annotation: Annotation | undefined): Dependency {
|
|
10
|
+
const labels = components().labels
|
|
11
|
+
|
|
12
|
+
const service: Service = {
|
|
13
|
+
group: 'exposition',
|
|
14
|
+
name: 'gateway',
|
|
15
|
+
port: 8000,
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
17
|
+
version: require('../package.json').version,
|
|
18
|
+
variables: [],
|
|
19
|
+
components: labels
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (annotation?.host !== undefined)
|
|
23
|
+
service.ingress = {
|
|
24
|
+
host: annotation.host,
|
|
25
|
+
class: annotation.class,
|
|
26
|
+
annotations: annotation.annotations
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (annotation?.['/'] !== undefined) {
|
|
30
|
+
const node = { '/': annotation['/'] }
|
|
31
|
+
const tree = parse(node, shortcuts)
|
|
32
|
+
|
|
33
|
+
service.variables.push({
|
|
34
|
+
name: 'TOA_EXPOSITION',
|
|
35
|
+
value: encode(tree)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (annotation?.debug === true)
|
|
40
|
+
service.variables.push({
|
|
41
|
+
name: 'TOA_EXPOSITION_DEBUG',
|
|
42
|
+
value: '1'
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if (annotation !== undefined)
|
|
46
|
+
schemas.annotaion.validate(annotation)
|
|
47
|
+
|
|
48
|
+
return { services: [service] }
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Directive, type Input } from './types'
|
|
2
|
+
|
|
3
|
+
export class Anonymous implements Directive {
|
|
4
|
+
private readonly allow: boolean
|
|
5
|
+
|
|
6
|
+
public constructor (allow: boolean) {
|
|
7
|
+
this.allow = allow
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public authorize (_: any, input: Input): boolean {
|
|
11
|
+
if ('authorization' in input.headers) return false
|
|
12
|
+
else return this.allow
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type OutgoingMessage } from '../../HTTP'
|
|
2
|
+
import { type Directive, type Identity } from './types'
|
|
3
|
+
|
|
4
|
+
export class Echo implements Directive {
|
|
5
|
+
public authorize (identity: Identity | null): boolean {
|
|
6
|
+
return identity !== null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
public reply (identity: Identity | null): OutgoingMessage {
|
|
10
|
+
return { body: identity }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type Component } from '@toa.io/core'
|
|
2
|
+
import { Nope } from 'nopeable'
|
|
3
|
+
import { type Parameter } from '../../RTD'
|
|
4
|
+
import { type Family, type Output } from '../../Directive'
|
|
5
|
+
import { type Remotes } from '../../Remotes'
|
|
6
|
+
import * as http from '../../HTTP'
|
|
7
|
+
import {
|
|
8
|
+
type AuthenticationResult,
|
|
9
|
+
type Ban,
|
|
10
|
+
type Directive,
|
|
11
|
+
type Discovery,
|
|
12
|
+
type Extension,
|
|
13
|
+
type Identity,
|
|
14
|
+
type Input, type Remote,
|
|
15
|
+
type Schemes
|
|
16
|
+
} from './types'
|
|
17
|
+
import { Anonymous } from './Anonymous'
|
|
18
|
+
import { Id } from './Id'
|
|
19
|
+
import { Role } from './Role'
|
|
20
|
+
import { Rule } from './Rule'
|
|
21
|
+
import { Incept } from './Incept'
|
|
22
|
+
import { split } from './split'
|
|
23
|
+
import { PRIMARY, PROVIDERS } from './schemes'
|
|
24
|
+
import { Scheme } from './Scheme'
|
|
25
|
+
import { Echo } from './Echo'
|
|
26
|
+
|
|
27
|
+
class Authorization implements Family<Directive, Extension> {
|
|
28
|
+
public readonly name: string = 'auth'
|
|
29
|
+
public readonly mandatory: boolean = true
|
|
30
|
+
private readonly schemes = {} as unknown as Schemes
|
|
31
|
+
private readonly discovery = {} as unknown as Discovery
|
|
32
|
+
private tokens: Component | null = null
|
|
33
|
+
private bans: Component | null = null
|
|
34
|
+
|
|
35
|
+
public create (name: string, value: any, remotes: Remotes): Directive {
|
|
36
|
+
const Class = CLASSES[name]
|
|
37
|
+
|
|
38
|
+
if (Class === undefined)
|
|
39
|
+
throw new Error(`Directive '${name}' is not provided by the '${this.name}' family.`)
|
|
40
|
+
|
|
41
|
+
for (const name of REMOTES)
|
|
42
|
+
this.discovery[name] ??= remotes.discover('identity', name)
|
|
43
|
+
|
|
44
|
+
if (Class === Role) return new Class(value, this.discovery.roles)
|
|
45
|
+
else if (Class === Rule) return new Class(value, this.create.bind(this))
|
|
46
|
+
else if (Class === Incept) return new Class(value, this.discovery)
|
|
47
|
+
else return new Class(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public async preflight
|
|
51
|
+
(directives: Directive[], input: Input, parameters: Parameter[]): Promise<Output> {
|
|
52
|
+
const identity = await this.resolve(input.headers.authorization)
|
|
53
|
+
|
|
54
|
+
input.identity = identity
|
|
55
|
+
|
|
56
|
+
for (const directive of directives) {
|
|
57
|
+
const allow = await directive.authorize(identity, input, parameters)
|
|
58
|
+
|
|
59
|
+
if (allow)
|
|
60
|
+
return directive.reply?.(identity) ?? null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (identity === null) throw new http.Unauthorized()
|
|
64
|
+
else throw new http.Forbidden()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public async settle
|
|
68
|
+
(directives: Directive[], request: Input, response: http.OutgoingMessage): Promise<void> {
|
|
69
|
+
for (const directive of directives)
|
|
70
|
+
await directive.settle?.(request, response)
|
|
71
|
+
|
|
72
|
+
const identity = request.identity
|
|
73
|
+
|
|
74
|
+
if (identity === null)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if (identity.scheme === PRIMARY && !identity.refresh)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
// Role directive may have already set the value
|
|
81
|
+
if (identity.roles === undefined)
|
|
82
|
+
await Role.set(identity, this.discovery.roles)
|
|
83
|
+
|
|
84
|
+
this.tokens ??= await this.discovery.tokens
|
|
85
|
+
|
|
86
|
+
const token = await this.tokens.invoke<string>('encrypt', { input: { identity } })
|
|
87
|
+
const authorization = `Token ${token}`
|
|
88
|
+
|
|
89
|
+
if (response.headers === undefined)
|
|
90
|
+
response.headers = {}
|
|
91
|
+
|
|
92
|
+
response.headers.authorization = authorization
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async resolve (authorization: string | undefined): Promise<Identity | null> {
|
|
96
|
+
if (authorization === undefined)
|
|
97
|
+
return null
|
|
98
|
+
|
|
99
|
+
const [scheme, credentials] = split(authorization)
|
|
100
|
+
const provider = PROVIDERS[scheme]
|
|
101
|
+
|
|
102
|
+
if (!(provider in this.discovery))
|
|
103
|
+
throw new http.Unauthorized(`Unknown authentication scheme '${scheme}'.`)
|
|
104
|
+
|
|
105
|
+
this.schemes[scheme] ??= await this.discovery[provider]
|
|
106
|
+
|
|
107
|
+
const result = await this.schemes[scheme]
|
|
108
|
+
.invoke<AuthenticationResult>('authenticate', { input: credentials })
|
|
109
|
+
|
|
110
|
+
if (result instanceof Nope)
|
|
111
|
+
return null
|
|
112
|
+
|
|
113
|
+
const identity = result.identity
|
|
114
|
+
|
|
115
|
+
if (scheme !== PRIMARY && await this.banned(identity))
|
|
116
|
+
throw new http.Unauthorized()
|
|
117
|
+
|
|
118
|
+
identity.scheme = scheme
|
|
119
|
+
identity.refresh = result.refresh
|
|
120
|
+
|
|
121
|
+
return identity
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async banned (identity: Identity): Promise<boolean> {
|
|
125
|
+
this.bans ??= await this.discovery.bans
|
|
126
|
+
|
|
127
|
+
const ban = await this.bans.invoke<Ban>('observe', { query: { id: identity.id } })
|
|
128
|
+
|
|
129
|
+
return ban.banned
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const CLASSES: Record<string, new (value: any, argument?: any) => Directive> = {
|
|
134
|
+
anonymous: Anonymous,
|
|
135
|
+
id: Id,
|
|
136
|
+
role: Role,
|
|
137
|
+
rule: Rule,
|
|
138
|
+
incept: Incept,
|
|
139
|
+
scheme: Scheme,
|
|
140
|
+
echo: Echo
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const REMOTES: Remote[] = ['basic', 'tokens', 'roles', 'bans']
|
|
144
|
+
|
|
145
|
+
export = new Authorization()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Parameter } from '../../RTD'
|
|
2
|
+
import { type Directive, type Identity } from './types'
|
|
3
|
+
|
|
4
|
+
export class Id implements Directive {
|
|
5
|
+
private readonly parameter: string
|
|
6
|
+
|
|
7
|
+
public constructor (parameter: string) {
|
|
8
|
+
this.parameter = parameter
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public authorize (identity: Identity | null, _: any, parameters: Parameter[]): boolean {
|
|
12
|
+
if (identity === null)
|
|
13
|
+
return false
|
|
14
|
+
|
|
15
|
+
const parameter = parameters.find((parameter) => parameter.name === this.parameter)
|
|
16
|
+
|
|
17
|
+
return parameter?.value === identity.id
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Nope, type Nopeable } from 'nopeable'
|
|
2
|
+
import * as http from '../../HTTP'
|
|
3
|
+
import { type Directive, type Discovery, type Identity, type Input, type Schemes } from './types'
|
|
4
|
+
import { split } from './split'
|
|
5
|
+
import { PROVIDERS } from './schemes'
|
|
6
|
+
|
|
7
|
+
export class Incept implements Directive {
|
|
8
|
+
private readonly property: string
|
|
9
|
+
private readonly discovery: Discovery
|
|
10
|
+
private readonly schemes: Schemes = {} as unknown as Schemes
|
|
11
|
+
|
|
12
|
+
public constructor (property: string, discovery: Discovery) {
|
|
13
|
+
this.property = property
|
|
14
|
+
this.discovery = discovery
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public authorize (identity: Identity | null, input: Input): boolean {
|
|
18
|
+
return identity === null && 'authorization' in input.headers
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async settle (request: Input, response: http.OutgoingMessage): Promise<void> {
|
|
22
|
+
const id = response.body?.[this.property]
|
|
23
|
+
|
|
24
|
+
if (id === undefined)
|
|
25
|
+
throw new http.Conflict('Identity inception has failed as the response body ' +
|
|
26
|
+
` does not contain the '${this.property}' property.`)
|
|
27
|
+
|
|
28
|
+
const [scheme, credentials] = split(request.headers.authorization as string)
|
|
29
|
+
const provider = PROVIDERS[scheme]
|
|
30
|
+
|
|
31
|
+
this.schemes[scheme] ??= await this.discovery[provider]
|
|
32
|
+
|
|
33
|
+
const identity = await this.schemes[scheme]
|
|
34
|
+
.invoke<Nopeable<Identity>>('create', { input: { id, credentials } })
|
|
35
|
+
|
|
36
|
+
if (identity instanceof Nope)
|
|
37
|
+
throw new http.Conflict(identity)
|
|
38
|
+
|
|
39
|
+
request.identity = identity
|
|
40
|
+
request.identity.scheme = scheme
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Component } from '@toa.io/core'
|
|
2
|
+
import { generate } from 'randomstring'
|
|
3
|
+
import { Role } from './Role'
|
|
4
|
+
import { type Identity } from './types'
|
|
5
|
+
|
|
6
|
+
const remote = {
|
|
7
|
+
invoke: jest.fn()
|
|
8
|
+
} as unknown as jest.MockedObject<Component>
|
|
9
|
+
|
|
10
|
+
const discovery = Promise.resolve(remote)
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should return false if not matched', async () => {
|
|
17
|
+
const roles = ['admin', 'user']
|
|
18
|
+
const directive = new Role(roles, discovery)
|
|
19
|
+
const identity: Identity = { id: generate(), scheme: '', refresh: false }
|
|
20
|
+
|
|
21
|
+
remote.invoke.mockResolvedValueOnce(['guest'])
|
|
22
|
+
|
|
23
|
+
const result = await directive.authorize(identity)
|
|
24
|
+
|
|
25
|
+
expect(result).toBe(false)
|
|
26
|
+
|
|
27
|
+
expect(remote.invoke)
|
|
28
|
+
.toBeCalledWith('list', { query: { criteria: `identity==${identity.id}`, limit: 1024 } })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should return true on exact match', async () => {
|
|
32
|
+
const result = await match(['admin', 'user'], ['user'])
|
|
33
|
+
|
|
34
|
+
expect(result).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should return true on scope match', async () => {
|
|
38
|
+
const result = await match(['system:identity:roles'], ['system'])
|
|
39
|
+
|
|
40
|
+
expect(result).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should return false on scope mismatch', async () => {
|
|
44
|
+
const result = await match(['system:identity'], ['system:identity:roles'])
|
|
45
|
+
|
|
46
|
+
expect(result).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should return false on non-scope substring match', async () => {
|
|
50
|
+
const result = await match(['system:identity'], ['system:iden'])
|
|
51
|
+
|
|
52
|
+
expect(result).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
async function match (expected: string[], actual: string[]): Promise<boolean> {
|
|
56
|
+
const directive = new Role(expected, discovery)
|
|
57
|
+
const identity: Identity = { id: generate(), scheme: '', refresh: false }
|
|
58
|
+
|
|
59
|
+
remote.invoke.mockResolvedValueOnce(actual)
|
|
60
|
+
|
|
61
|
+
return await directive.authorize(identity)
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Component, type Query } from '@toa.io/core'
|
|
2
|
+
import { type Directive, type Identity } from './types'
|
|
3
|
+
|
|
4
|
+
export class Role implements Directive {
|
|
5
|
+
public static remote: Component | null = null
|
|
6
|
+
private readonly roles: string[]
|
|
7
|
+
private readonly discovery: Promise<Component>
|
|
8
|
+
|
|
9
|
+
public constructor (roles: string | string[], discovery: Promise<Component>) {
|
|
10
|
+
this.roles = typeof roles === 'string' ? [roles] : roles
|
|
11
|
+
this.discovery = discovery
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public static async set (identity: Identity, discovery: Promise<Component>): Promise<void> {
|
|
15
|
+
this.remote ??= await discovery
|
|
16
|
+
|
|
17
|
+
const query: Query = { criteria: `identity==${identity.id}`, limit: 1024 }
|
|
18
|
+
const roles: string[] = await this.remote.invoke('list', { query })
|
|
19
|
+
|
|
20
|
+
identity.roles = roles
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public async authorize (identity: Identity | null): Promise<boolean> {
|
|
24
|
+
if (identity === null)
|
|
25
|
+
return false
|
|
26
|
+
|
|
27
|
+
await Role.set(identity, this.discovery)
|
|
28
|
+
|
|
29
|
+
if (identity.roles === undefined)
|
|
30
|
+
return false
|
|
31
|
+
|
|
32
|
+
return this.match(identity.roles)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private match (roles: string[]): boolean {
|
|
36
|
+
for (const role of roles) {
|
|
37
|
+
const index = this.roles.findIndex((expected) => compare(expected, role))
|
|
38
|
+
|
|
39
|
+
if (index !== -1)
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compare (expected: string, actual: string): boolean {
|
|
48
|
+
const exp = expected.split(':')
|
|
49
|
+
const act = actual.split(':')
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < act.length; i++)
|
|
52
|
+
if (exp[i] !== act[i])
|
|
53
|
+
return false
|
|
54
|
+
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Parameter } from '../../RTD'
|
|
2
|
+
import { type Directive, type Identity } from './types'
|
|
3
|
+
|
|
4
|
+
export class Rule implements Directive {
|
|
5
|
+
private readonly directives: Directive[] = []
|
|
6
|
+
|
|
7
|
+
public constructor (directives: Record<string, any>, create: Create) {
|
|
8
|
+
for (const [name, value] of Object.entries(directives)) {
|
|
9
|
+
const directive = create(name, value)
|
|
10
|
+
|
|
11
|
+
this.directives.push(directive)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async authorize
|
|
16
|
+
(identity: Identity | null, input: any, parameters: Parameter[]): Promise<boolean> {
|
|
17
|
+
for (const directive of this.directives) {
|
|
18
|
+
const authorized = await directive.authorize(identity, input, parameters)
|
|
19
|
+
|
|
20
|
+
if (!authorized)
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Create = (name: string, value: any) => Directive
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as http from '../../HTTP'
|
|
2
|
+
import { type Directive, type Identity, type Input } from './types'
|
|
3
|
+
import { split } from './split'
|
|
4
|
+
|
|
5
|
+
export class Scheme implements Directive {
|
|
6
|
+
private readonly scheme: string
|
|
7
|
+
private readonly Scheme: string
|
|
8
|
+
|
|
9
|
+
public constructor (scheme: string) {
|
|
10
|
+
this.scheme = scheme.toLowerCase()
|
|
11
|
+
this.Scheme = scheme[0].toUpperCase() + scheme.substring(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public authorize (_: Identity | null, input: Input): boolean {
|
|
15
|
+
if (input.headers.authorization === undefined)
|
|
16
|
+
return false
|
|
17
|
+
|
|
18
|
+
const [scheme] = split(input.headers.authorization)
|
|
19
|
+
|
|
20
|
+
if (scheme !== this.scheme)
|
|
21
|
+
throw new http.Forbidden(this.Scheme +
|
|
22
|
+
' authentication scheme is required to access this resource.')
|
|
23
|
+
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as http from '../../HTTP'
|
|
2
|
+
import { type Scheme } from './types'
|
|
3
|
+
|
|
4
|
+
export function split (authorization: string): [Scheme, string] {
|
|
5
|
+
const space = authorization.indexOf(' ')
|
|
6
|
+
|
|
7
|
+
if (space === -1)
|
|
8
|
+
throw new http.Unauthorized('Malformed authorization header.')
|
|
9
|
+
|
|
10
|
+
const Scheme = authorization.slice(0, space)
|
|
11
|
+
const scheme = Scheme.toLowerCase() as Scheme
|
|
12
|
+
const value = authorization.slice(space + 1)
|
|
13
|
+
|
|
14
|
+
return [scheme, value]
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Component } from '@toa.io/core'
|
|
2
|
+
import { type Nopeable } from 'nopeable'
|
|
3
|
+
import { type Parameter } from '../../RTD'
|
|
4
|
+
import type * as http from '../../HTTP'
|
|
5
|
+
import type * as directive from '../../Directive'
|
|
6
|
+
|
|
7
|
+
export interface Directive {
|
|
8
|
+
authorize: (identity: Identity | null, input: Input, parameters: Parameter[]) =>
|
|
9
|
+
boolean | Promise<boolean>
|
|
10
|
+
|
|
11
|
+
reply?: (identity: Identity | null) => http.OutgoingMessage
|
|
12
|
+
|
|
13
|
+
settle?: (request: Input, response: http.OutgoingMessage) => Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Identity {
|
|
17
|
+
readonly id: string
|
|
18
|
+
scheme: string
|
|
19
|
+
roles?: string[]
|
|
20
|
+
refresh: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Extension {
|
|
24
|
+
identity: Identity | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Ban {
|
|
28
|
+
banned: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Input = directive.Input & Extension
|
|
32
|
+
export type AuthenticationResult = Nopeable<{ identity: Identity, refresh: boolean }>
|
|
33
|
+
|
|
34
|
+
export type Scheme = 'basic' | 'token'
|
|
35
|
+
export type Remote = 'basic' | 'tokens' | 'roles' | 'bans'
|
|
36
|
+
export type Discovery = Record<Remote, Promise<Component>>
|
|
37
|
+
export type Schemes = Record<Scheme, Component>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Input, type Output, type Family } from '../../Directive'
|
|
2
|
+
import { Stub } from './Stub'
|
|
3
|
+
import { Throw } from './Throw'
|
|
4
|
+
import { type Directive } from './types'
|
|
5
|
+
|
|
6
|
+
class Development implements Family<Directive> {
|
|
7
|
+
public readonly name: string = 'dev'
|
|
8
|
+
public readonly mandatory: boolean = false
|
|
9
|
+
|
|
10
|
+
public create (name: string, value: any): Directive {
|
|
11
|
+
const Class = constructors[name]
|
|
12
|
+
|
|
13
|
+
if (Class === undefined)
|
|
14
|
+
throw new Error(`Directive '${name}' is not provided by the '${this.name}' family.`)
|
|
15
|
+
|
|
16
|
+
return new Class(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public preflight (directives: Directive[], input: Input): Output {
|
|
20
|
+
for (const directive of directives) {
|
|
21
|
+
const output = directive.apply(input)
|
|
22
|
+
|
|
23
|
+
if (output !== null)
|
|
24
|
+
return output
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const constructors: Record<string, new (value: any) => Directive> = {
|
|
32
|
+
stub: Stub,
|
|
33
|
+
throw: Throw
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export = new Development()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Output } from '../../Directive'
|
|
2
|
+
import { type Directive } from './types'
|
|
3
|
+
|
|
4
|
+
export class Stub implements Directive {
|
|
5
|
+
private readonly value: any
|
|
6
|
+
|
|
7
|
+
public constructor (value: any) {
|
|
8
|
+
this.value = value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public apply (): Output {
|
|
12
|
+
return { body: this.value }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Output } from '../../Directive'
|
|
2
|
+
import { type Directive } from './types'
|
|
3
|
+
|
|
4
|
+
export class Throw implements Directive {
|
|
5
|
+
private readonly message: any
|
|
6
|
+
|
|
7
|
+
public constructor (message: any) {
|
|
8
|
+
this.message = message
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public apply (): Output {
|
|
12
|
+
throw new Error(this.message)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Label = 'ping' | 'expose'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Exception } from '@toa.io/core'
|
|
2
|
+
import * as http from './HTTP'
|
|
3
|
+
|
|
4
|
+
export function rethrow (exception: Exception): void {
|
|
5
|
+
// see /runtime/core/src/exceptions.js
|
|
6
|
+
|
|
7
|
+
if ((exception.code >= 200 && exception.code < 210) || exception.code === 221)
|
|
8
|
+
throw new http.BadRequest(exception.message)
|
|
9
|
+
|
|
10
|
+
if (exception.code === 302)
|
|
11
|
+
throw new http.NotFound()
|
|
12
|
+
|
|
13
|
+
if (exception.code === 303)
|
|
14
|
+
throw new http.PreconditionFailed()
|
|
15
|
+
|
|
16
|
+
throw exception as unknown as Error
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebStorm can't find jest types if they are in the root of the project,
|
|
3
|
+
* while a current project has its own node_modules.
|
|
4
|
+
*
|
|
5
|
+
* Importing jest here fixes the issue.
|
|
6
|
+
*/
|
|
7
|
+
import 'jest'
|
|
8
|
+
|
|
9
|
+
it.skip('', () => undefined) // prevent jest from complaining about missing tests
|