@toa.io/extensions.exposition 1.0.0-alpha.273 → 1.0.0-alpha.274
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/CHANGELOG.md +197 -0
- package/components/identity.basic/manifest.toa.yaml +1 -1
- package/components/identity.basic/operations/authenticate.js +1 -1
- package/components/identity.basic/operations/authenticate.js.map +1 -1
- package/components/identity.basic/operations/transit.js +1 -1
- package/components/identity.basic/operations/transit.js.map +1 -1
- package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.basic/operations/types.d.ts +2 -2
- package/components/identity.basic/source/authenticate.ts +1 -1
- package/components/identity.basic/source/transit.ts +1 -1
- package/components/identity.basic/source/types.ts +2 -2
- package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/operations/lib/exchange.js +2 -2
- package/components/identity.federation/operations/lib/exchange.js.map +1 -1
- package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/operations/types/configuration.d.ts +3 -2
- package/components/identity.federation/source/lib/exchange.ts +2 -2
- package/components/identity.federation/source/types/configuration.ts +4 -2
- package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.tokens/operations/decrypt.js +1 -1
- package/components/identity.tokens/operations/decrypt.js.map +1 -1
- package/components/identity.tokens/operations/encrypt.js +1 -1
- package/components/identity.tokens/operations/encrypt.js.map +1 -1
- package/components/identity.tokens/operations/lib/types.d.ts +3 -2
- package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.tokens/source/authenticate.test.ts +7 -2
- package/components/identity.tokens/source/decrypt.test.ts +13 -7
- package/components/identity.tokens/source/decrypt.ts +1 -1
- package/components/identity.tokens/source/encrypt.test.ts +9 -4
- package/components/identity.tokens/source/encrypt.ts +1 -1
- package/components/identity.tokens/source/lib/types.ts +3 -2
- package/context.toa.yaml +2 -2
- package/documentation/components.md +1 -1
- package/features/configuration.feature +207 -0
- package/features/identity.federation.feature +15 -7
- package/features/introspection.map.feature +16 -0
- package/features/steps/Components.ts +29 -0
- package/features/steps/Gateway.ts +48 -10
- package/package.json +3 -3
- package/source/HTTP/formats/yaml.test.ts +6 -0
- package/source/HTTP/formats/yaml.ts +21 -1
- package/transpiled/HTTP/formats/yaml.js +15 -1
- package/transpiled/HTTP/formats/yaml.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { generate } from 'randomstring'
|
|
2
2
|
import { Computation as Authenticate } from './authenticate'
|
|
3
3
|
import type { Configuration, Context, DecryptOutput, Identity } from './lib'
|
|
4
|
+
import type { Secret } from '@toa.io/types'
|
|
4
5
|
|
|
5
6
|
let configuration: Configuration
|
|
6
7
|
let context: Context
|
|
@@ -13,8 +14,8 @@ const authority = generate()
|
|
|
13
14
|
beforeEach(() => {
|
|
14
15
|
configuration = {
|
|
15
16
|
keys: [
|
|
16
|
-
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
17
|
-
{ id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
|
|
17
|
+
{ id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
|
|
18
|
+
{ id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' }
|
|
18
19
|
],
|
|
19
20
|
lifetime: 2592000,
|
|
20
21
|
refresh: 600,
|
|
@@ -68,3 +69,7 @@ it.each([true, false])('should return stale: %s',
|
|
|
68
69
|
|
|
69
70
|
expect(result).toEqual({ identity, refresh })
|
|
70
71
|
})
|
|
72
|
+
|
|
73
|
+
function secret (value: string): Secret {
|
|
74
|
+
return { unwrap: () => value }
|
|
75
|
+
}
|
|
@@ -3,6 +3,7 @@ import { V3 } from 'paseto'
|
|
|
3
3
|
import { Effect as Encrypt } from './encrypt'
|
|
4
4
|
import { Computation as Decrypt } from './decrypt'
|
|
5
5
|
import { type Configuration, type Context, type Identity } from './lib'
|
|
6
|
+
import type { Secret } from '@toa.io/types'
|
|
6
7
|
|
|
7
8
|
let configuration: Configuration
|
|
8
9
|
let context: Context
|
|
@@ -15,10 +16,11 @@ const authority = generate()
|
|
|
15
16
|
beforeEach(() => {
|
|
16
17
|
configuration = {
|
|
17
18
|
keys: [
|
|
18
|
-
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
19
|
-
|
|
20
|
-
{ id: '
|
|
21
|
-
{ id: '
|
|
19
|
+
{ id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
|
|
20
|
+
// keys are secrets, and a secret is an object
|
|
21
|
+
{ id: 'key1', key: secret('5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0') },
|
|
22
|
+
{ id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' },
|
|
23
|
+
{ id: 'legacy1', key: secret('k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA'), format: 'paseto' }
|
|
22
24
|
],
|
|
23
25
|
lifetime: 1000,
|
|
24
26
|
refresh: 500,
|
|
@@ -77,7 +79,7 @@ it('should decrypt with key1', async () => {
|
|
|
77
79
|
it('should decrypt legacy PASETO and require refresh', async () => {
|
|
78
80
|
const identity: Identity = { id: generate(), roles: [] }
|
|
79
81
|
|
|
80
|
-
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
|
|
82
|
+
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key.unwrap(), {
|
|
81
83
|
footer: { kid: 'legacy0' }
|
|
82
84
|
})
|
|
83
85
|
|
|
@@ -96,7 +98,7 @@ it('should separate JWE and PASETO keys with the same id by format', async () =>
|
|
|
96
98
|
|
|
97
99
|
const identity: Identity = { id: generate(), roles: [] }
|
|
98
100
|
|
|
99
|
-
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
|
|
101
|
+
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key.unwrap(), {
|
|
100
102
|
footer: { kid: 'key0' }
|
|
101
103
|
})
|
|
102
104
|
|
|
@@ -125,7 +127,7 @@ it('should reject JWE with an unknown key', async () => {
|
|
|
125
127
|
const token = await encrypt.execute({
|
|
126
128
|
authority,
|
|
127
129
|
identity: { id: generate(), roles: [] },
|
|
128
|
-
key: { id: 'missing', key: configuration.keys[0].key, label: 'missing' }
|
|
130
|
+
key: { id: 'missing', key: configuration.keys[0].key.unwrap(), label: 'missing' }
|
|
129
131
|
})
|
|
130
132
|
|
|
131
133
|
if (token instanceof Error)
|
|
@@ -133,3 +135,7 @@ it('should reject JWE with an unknown key', async () => {
|
|
|
133
135
|
|
|
134
136
|
await expect(decrypt.execute(token)).resolves.toMatchObject({ code: 'INVALID_KEY' })
|
|
135
137
|
})
|
|
138
|
+
|
|
139
|
+
function secret (value: string): Secret {
|
|
140
|
+
return { unwrap: () => value }
|
|
141
|
+
}
|
|
@@ -4,6 +4,7 @@ import { timeout } from '@toa.io/generic'
|
|
|
4
4
|
import { Effect as Encrypt } from './encrypt'
|
|
5
5
|
import { Computation as Decrypt } from './decrypt'
|
|
6
6
|
import { type Context, type Identity } from './lib'
|
|
7
|
+
import type { Secret } from '@toa.io/types'
|
|
7
8
|
|
|
8
9
|
let encrypt: Encrypt
|
|
9
10
|
let decrypt: Decrypt
|
|
@@ -18,8 +19,8 @@ const authority = generate()
|
|
|
18
19
|
beforeEach(() => {
|
|
19
20
|
context.configuration = {
|
|
20
21
|
keys: [
|
|
21
|
-
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
22
|
-
{ id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
|
|
22
|
+
{ id: 'key0', key: secret('sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs') },
|
|
23
|
+
{ id: 'legacy0', key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'), format: 'paseto' }
|
|
23
24
|
],
|
|
24
25
|
lifetime: 1,
|
|
25
26
|
refresh: 2,
|
|
@@ -39,12 +40,12 @@ beforeEach(() => {
|
|
|
39
40
|
it('should use the first encryption key as active and expose its id as kid', async () => {
|
|
40
41
|
context.configuration.keys.unshift({
|
|
41
42
|
id: 'legacy-first',
|
|
42
|
-
key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
|
|
43
|
+
key: secret('k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'),
|
|
43
44
|
format: 'paseto'
|
|
44
45
|
})
|
|
45
46
|
context.configuration.keys.push({
|
|
46
47
|
id: 'key1',
|
|
47
|
-
key: '5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0'
|
|
48
|
+
key: secret('5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0')
|
|
48
49
|
})
|
|
49
50
|
|
|
50
51
|
encrypt.mount(context)
|
|
@@ -119,3 +120,7 @@ it('should encrypt without lifetime INSECURE', async () => {
|
|
|
119
120
|
|
|
120
121
|
expect(decrypted.identity).toMatchObject(identity)
|
|
121
122
|
})
|
|
123
|
+
|
|
124
|
+
function secret (value: string): Secret {
|
|
125
|
+
return { unwrap: () => value }
|
|
126
|
+
}
|
|
@@ -13,7 +13,7 @@ export class Effect implements Operation {
|
|
|
13
13
|
if (key === undefined)
|
|
14
14
|
throw new TypeError('At least one JWE key must be configured')
|
|
15
15
|
|
|
16
|
-
this.key = key
|
|
16
|
+
this.key = { id: key.id, key: key.key.unwrap() }
|
|
17
17
|
this.lifetime = context.configuration.lifetime * 1000
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Call, Maybe, Observation, telemetry } from '@toa.io/types'
|
|
1
|
+
import type { Call, Maybe, Observation, Secret, telemetry } from '@toa.io/types'
|
|
2
2
|
|
|
3
3
|
export interface Context {
|
|
4
4
|
local: {
|
|
@@ -31,7 +31,8 @@ export interface Configuration {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export interface ConfiguredKey extends Pick<Key, 'id'
|
|
34
|
+
export interface ConfiguredKey extends Pick<Key, 'id'> {
|
|
35
|
+
key: Secret
|
|
35
36
|
format?: 'jwe' | 'paseto'
|
|
36
37
|
}
|
|
37
38
|
|
package/context.toa.yaml
CHANGED
|
@@ -10,9 +10,9 @@ configuration:
|
|
|
10
10
|
identity.tokens:
|
|
11
11
|
keys:
|
|
12
12
|
- id: key0
|
|
13
|
-
key:
|
|
13
|
+
key: $IDENTITY_TOKENS_ENCRYPTION_KEY0
|
|
14
14
|
- id: legacy0
|
|
15
|
-
key:
|
|
15
|
+
key: $IDENTITY_TOKENS_KEY0
|
|
16
16
|
format: paseto
|
|
17
17
|
|
|
18
18
|
identity.federation:
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
@security
|
|
2
|
+
Feature: Configuration values
|
|
3
|
+
|
|
4
|
+
The values component declares how it is exposed in its own manifest, and that manifest
|
|
5
|
+
is what this mounts. Reading takes `system:configuration:get`, creating takes
|
|
6
|
+
`system:configuration:create`, and the one who creates is recorded as the originator.
|
|
7
|
+
|
|
8
|
+
Background:
|
|
9
|
+
# developer:secret, user:12345
|
|
10
|
+
Given the `identity.basic` database contains:
|
|
11
|
+
| _id | authority | username | password |
|
|
12
|
+
| efe3a65ebbee47ed95a73edd911ea328 | nex | developer | $2b$10$ZRSKkgZoGnrcTNA5w5eCcu3pxDzdTduhteVYXcp56AaNcilNkwJ.O |
|
|
13
|
+
| e8e4f9c2a68d419b861403d71fabc915 | nex | user | $2b$10$Frszmrmsz9iwSXzBbRRMKeDVKsNxozkrLNSsN.SnVC.KPxLtQr/bK |
|
|
14
|
+
And the `identity.roles` database contains:
|
|
15
|
+
| _id | identity | role |
|
|
16
|
+
| 1c0b2a3d4e5f4a6b8c7d9e0f1a2b3c4d | efe3a65ebbee47ed95a73edd911ea328 | system:configuration |
|
|
17
|
+
| 2c0b2a3d4e5f4a6b8c7d9e0f1a2b3c4d | e8e4f9c2a68d419b861403d71fabc915 | system:configuration:get |
|
|
18
|
+
And the `identity.bans` database is empty
|
|
19
|
+
And the `configuration.values` database is empty
|
|
20
|
+
And the configuration values are deployed:
|
|
21
|
+
"""yaml
|
|
22
|
+
dummies.dummy:
|
|
23
|
+
epoch: e1
|
|
24
|
+
schema:
|
|
25
|
+
type: object
|
|
26
|
+
properties:
|
|
27
|
+
foo:
|
|
28
|
+
type: string
|
|
29
|
+
bar:
|
|
30
|
+
type: string
|
|
31
|
+
default: world
|
|
32
|
+
defaults:
|
|
33
|
+
foo: deployed
|
|
34
|
+
"""
|
|
35
|
+
And the configuration values are running
|
|
36
|
+
|
|
37
|
+
Scenario: Reading the deployed configuration
|
|
38
|
+
When the following request is received:
|
|
39
|
+
"""
|
|
40
|
+
GET /configuration/values/dummies.dummy/ HTTP/1.1
|
|
41
|
+
host: nex.toa.io
|
|
42
|
+
authorization: Basic dXNlcjoxMjM0NQ==
|
|
43
|
+
accept: application/yaml
|
|
44
|
+
"""
|
|
45
|
+
Then the following reply is sent:
|
|
46
|
+
"""
|
|
47
|
+
200 OK
|
|
48
|
+
content-type: application/yaml
|
|
49
|
+
|
|
50
|
+
configuration:
|
|
51
|
+
foo: deployed
|
|
52
|
+
schema:
|
|
53
|
+
type: object
|
|
54
|
+
properties:
|
|
55
|
+
foo:
|
|
56
|
+
type: string
|
|
57
|
+
bar:
|
|
58
|
+
type: string
|
|
59
|
+
default: world
|
|
60
|
+
epoch: e1
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
Scenario: Listing the configurations
|
|
64
|
+
When the following request is received:
|
|
65
|
+
"""
|
|
66
|
+
GET /configuration/values/ HTTP/1.1
|
|
67
|
+
host: nex.toa.io
|
|
68
|
+
authorization: Basic dXNlcjoxMjM0NQ==
|
|
69
|
+
accept: application/yaml
|
|
70
|
+
"""
|
|
71
|
+
Then the following reply is sent:
|
|
72
|
+
"""
|
|
73
|
+
200 OK
|
|
74
|
+
content-type: application/yaml
|
|
75
|
+
|
|
76
|
+
- component: dummies.dummy
|
|
77
|
+
epoch: e1
|
|
78
|
+
schema:
|
|
79
|
+
type: object
|
|
80
|
+
properties:
|
|
81
|
+
foo:
|
|
82
|
+
type: string
|
|
83
|
+
bar:
|
|
84
|
+
type: string
|
|
85
|
+
default: world
|
|
86
|
+
configuration:
|
|
87
|
+
foo: deployed
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
Scenario: Listing without the role
|
|
91
|
+
When the following request is received:
|
|
92
|
+
"""
|
|
93
|
+
GET /configuration/values/ HTTP/1.1
|
|
94
|
+
host: nex.toa.io
|
|
95
|
+
"""
|
|
96
|
+
Then the following reply is sent:
|
|
97
|
+
"""
|
|
98
|
+
401 Unauthorized
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
Scenario: Reading without the role
|
|
102
|
+
When the following request is received:
|
|
103
|
+
"""
|
|
104
|
+
GET /configuration/values/dummies.dummy/ HTTP/1.1
|
|
105
|
+
host: nex.toa.io
|
|
106
|
+
"""
|
|
107
|
+
Then the following reply is sent:
|
|
108
|
+
"""
|
|
109
|
+
401 Unauthorized
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
Scenario: Reading the configuration of an unknown component
|
|
113
|
+
When the following request is received:
|
|
114
|
+
"""
|
|
115
|
+
GET /configuration/values/dummies.unknown/ HTTP/1.1
|
|
116
|
+
host: nex.toa.io
|
|
117
|
+
authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
|
|
118
|
+
"""
|
|
119
|
+
Then the following reply is sent:
|
|
120
|
+
"""
|
|
121
|
+
404 Not Found
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
Scenario: Creating configuration
|
|
125
|
+
When the following request is received:
|
|
126
|
+
"""
|
|
127
|
+
POST /configuration/values/dummies.dummy/ HTTP/1.1
|
|
128
|
+
host: nex.toa.io
|
|
129
|
+
authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
|
|
130
|
+
accept: application/yaml
|
|
131
|
+
content-type: application/yaml
|
|
132
|
+
|
|
133
|
+
configuration:
|
|
134
|
+
foo: created
|
|
135
|
+
"""
|
|
136
|
+
Then the following reply is sent:
|
|
137
|
+
"""
|
|
138
|
+
201 Created
|
|
139
|
+
content-type: application/yaml
|
|
140
|
+
|
|
141
|
+
epoch: e1
|
|
142
|
+
"""
|
|
143
|
+
When the following request is received:
|
|
144
|
+
"""
|
|
145
|
+
GET /configuration/values/dummies.dummy/ HTTP/1.1
|
|
146
|
+
host: nex.toa.io
|
|
147
|
+
authorization: Basic dXNlcjoxMjM0NQ==
|
|
148
|
+
accept: application/yaml
|
|
149
|
+
"""
|
|
150
|
+
Then the following reply is sent:
|
|
151
|
+
"""
|
|
152
|
+
200 OK
|
|
153
|
+
content-type: application/yaml
|
|
154
|
+
|
|
155
|
+
configuration:
|
|
156
|
+
foo: created
|
|
157
|
+
bar: world
|
|
158
|
+
epoch: e1
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
Scenario: Creating configuration without the role
|
|
162
|
+
When the following request is received:
|
|
163
|
+
"""
|
|
164
|
+
POST /configuration/values/dummies.dummy/ HTTP/1.1
|
|
165
|
+
host: nex.toa.io
|
|
166
|
+
authorization: Basic dXNlcjoxMjM0NQ==
|
|
167
|
+
content-type: application/yaml
|
|
168
|
+
|
|
169
|
+
configuration:
|
|
170
|
+
foo: created
|
|
171
|
+
"""
|
|
172
|
+
Then the following reply is sent:
|
|
173
|
+
"""
|
|
174
|
+
403 Forbidden
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
Scenario: Creating configuration not fitting the schema
|
|
178
|
+
When the following request is received:
|
|
179
|
+
"""
|
|
180
|
+
POST /configuration/values/dummies.dummy/ HTTP/1.1
|
|
181
|
+
host: nex.toa.io
|
|
182
|
+
authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
|
|
183
|
+
content-type: application/yaml
|
|
184
|
+
|
|
185
|
+
configuration:
|
|
186
|
+
foo:
|
|
187
|
+
nested: true
|
|
188
|
+
"""
|
|
189
|
+
Then the following reply is sent:
|
|
190
|
+
"""
|
|
191
|
+
422 Unprocessable Entity
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
Scenario: Creating configuration for an unknown component
|
|
195
|
+
When the following request is received:
|
|
196
|
+
"""
|
|
197
|
+
POST /configuration/values/dummies.unknown/ HTTP/1.1
|
|
198
|
+
host: nex.toa.io
|
|
199
|
+
authorization: Basic ZGV2ZWxvcGVyOnNlY3JldA==
|
|
200
|
+
content-type: application/yaml
|
|
201
|
+
|
|
202
|
+
configuration: {}
|
|
203
|
+
"""
|
|
204
|
+
Then the following reply is sent:
|
|
205
|
+
"""
|
|
206
|
+
422 Unprocessable Entity
|
|
207
|
+
"""
|
|
@@ -335,7 +335,11 @@ Feature: Identity Federation
|
|
|
335
335
|
trust:
|
|
336
336
|
- iss: http://localhost:44444
|
|
337
337
|
aud: nex
|
|
338
|
-
secret:
|
|
338
|
+
secret: $IDP_SECRET
|
|
339
|
+
"""
|
|
340
|
+
And the configuration secrets:
|
|
341
|
+
"""yaml
|
|
342
|
+
IDP_SECRET: secret
|
|
339
343
|
"""
|
|
340
344
|
And auth code for Alice is issued for https://web.toa.io/callback/
|
|
341
345
|
When the following request is received:
|
|
@@ -362,12 +366,16 @@ Feature: Identity Federation
|
|
|
362
366
|
signature:
|
|
363
367
|
iss: io.toa.nex.id
|
|
364
368
|
kid: key-id
|
|
365
|
-
key:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
key: $IDP_KEY
|
|
370
|
+
"""
|
|
371
|
+
And the configuration secrets:
|
|
372
|
+
"""yaml
|
|
373
|
+
IDP_KEY: |
|
|
374
|
+
-----BEGIN PRIVATE KEY-----
|
|
375
|
+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9x9DatwH0GZHSCo9
|
|
376
|
+
TMHTVXyeY0YQ8qb73jHV2v4MsyehRANCAAQwaYlna2hSV3G/RIlNLV41lg8Pm4Kf
|
|
377
|
+
HfCuKKiw3BIJTnSArAMJLSy1vYwSIMHz720mkmWTrWuQkkjvkDpZxfRv
|
|
378
|
+
-----END PRIVATE KEY-----
|
|
371
379
|
"""
|
|
372
380
|
And auth code for Bob is issued for https://web.toa.io/callback/
|
|
373
381
|
When the following request is received:
|
|
@@ -90,3 +90,19 @@ Feature: Reading the introspection map
|
|
|
90
90
|
"""
|
|
91
91
|
403 Forbidden
|
|
92
92
|
"""
|
|
93
|
+
|
|
94
|
+
Scenario: The map does not take the application's root directives
|
|
95
|
+
Given the annotation of the introspection map under:
|
|
96
|
+
"""yaml
|
|
97
|
+
/:
|
|
98
|
+
auth:anonymous: true
|
|
99
|
+
"""
|
|
100
|
+
When the following request is received:
|
|
101
|
+
"""
|
|
102
|
+
GET /introspection/nodes/ HTTP/1.1
|
|
103
|
+
host: nex.toa.io
|
|
104
|
+
"""
|
|
105
|
+
Then the following reply is sent:
|
|
106
|
+
"""
|
|
107
|
+
401 Unauthorized
|
|
108
|
+
"""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as assert from 'node:assert'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
2
3
|
import { after, binding, given } from 'cucumber-tsflow'
|
|
3
4
|
import * as boot from '@toa.io/boot'
|
|
4
5
|
import { timeout } from '@toa.io/generic'
|
|
@@ -9,6 +10,7 @@ import { Workspace } from './Workspace'
|
|
|
9
10
|
import { components as map } from './map'
|
|
10
11
|
|
|
11
12
|
const MAP = 'introspection'
|
|
13
|
+
const VALUES = 'configuration'
|
|
12
14
|
|
|
13
15
|
@binding([Workspace, Gateway])
|
|
14
16
|
export class Components {
|
|
@@ -44,6 +46,25 @@ export class Components {
|
|
|
44
46
|
await timeout(50) // discovery
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** The values component, as the configuration extension ships it; the service hosts it alone. */
|
|
50
|
+
@given('the configuration values are running')
|
|
51
|
+
public async runValues (): Promise<void> {
|
|
52
|
+
assert.ok(!(VALUES in this.compositions), `Composition '${VALUES}' is already running`)
|
|
53
|
+
|
|
54
|
+
await this.gateway.start()
|
|
55
|
+
|
|
56
|
+
this.compositions[VALUES] = await boot.composition([values()])
|
|
57
|
+
|
|
58
|
+
await this.compositions[VALUES].connect()
|
|
59
|
+
await timeout(50) // discovery
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the deployment would tell the values service, as the scenario needs it. */
|
|
63
|
+
@given('the configuration values are deployed:')
|
|
64
|
+
public deployValues (yaml: string): void {
|
|
65
|
+
process.env.TOA_CONFIGURATION_VALUES = JSON.stringify(parse(yaml))
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
@given('the `{word}` is stopped')
|
|
48
69
|
public async stop (name: string): Promise<void> {
|
|
49
70
|
assert.ok(name in this.compositions, `Composition '${name}' is not running`)
|
|
@@ -57,6 +78,8 @@ export class Components {
|
|
|
57
78
|
const promises = Object.values(this.compositions).map((composition) => composition.disconnect())
|
|
58
79
|
|
|
59
80
|
await Promise.all(promises)
|
|
81
|
+
|
|
82
|
+
delete process.env.TOA_CONFIGURATION_VALUES
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
private async runComponent (name: string, manifest?: object): Promise<void> {
|
|
@@ -75,3 +98,9 @@ export class Components {
|
|
|
75
98
|
await timeout(50) // discovery
|
|
76
99
|
}
|
|
77
100
|
}
|
|
101
|
+
|
|
102
|
+
function values (): string {
|
|
103
|
+
const root = dirname(require.resolve('@toa.io/extensions.configuration/package.json'))
|
|
104
|
+
|
|
105
|
+
return join(root, 'components', 'configuration.values')
|
|
106
|
+
}
|
|
@@ -14,6 +14,7 @@ let instance: Connector | null = null
|
|
|
14
14
|
@binding()
|
|
15
15
|
export class Gateway {
|
|
16
16
|
private default: boolean = true
|
|
17
|
+
private written: string[] = []
|
|
17
18
|
|
|
18
19
|
@given('the annotation:')
|
|
19
20
|
public async annotate (yaml: string): Promise<void> {
|
|
@@ -47,7 +48,15 @@ export class Gateway {
|
|
|
47
48
|
*/
|
|
48
49
|
@given('the annotation of the introspection map')
|
|
49
50
|
public async annotateMap (): Promise<void> {
|
|
50
|
-
|
|
51
|
+
await this.annotateMapUnder()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The same routes under a root of the scenario's own: what an application declares around the map. */
|
|
55
|
+
@given('the annotation of the introspection map under:')
|
|
56
|
+
public async annotateMapUnder (yaml?: string): Promise<void> {
|
|
57
|
+
const tree: syntax.Node = yaml === undefined
|
|
58
|
+
? { routes: [], methods: [], directives: [] }
|
|
59
|
+
: syntax.parse((parse(yaml) as { '/': object })['/'], shortcuts)
|
|
51
60
|
|
|
52
61
|
for (const manifest of await manifests()) {
|
|
53
62
|
const node = manifest.extensions?.[EXPOSITION] as syntax.Node | undefined
|
|
@@ -81,6 +90,21 @@ export class Gateway {
|
|
|
81
90
|
this.default = false
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/** The secrets a scenario's configuration refers to. */
|
|
94
|
+
@given('the configuration secrets:')
|
|
95
|
+
public async secrets (yaml: string): Promise<void> {
|
|
96
|
+
const secrets = parse(yaml) as Record<string, string>
|
|
97
|
+
|
|
98
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
99
|
+
this.written.push(SECRET + name)
|
|
100
|
+
process.env[SECRET + name] = value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await Gateway.stop()
|
|
104
|
+
|
|
105
|
+
this.default = false
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
@given('the branch TTL is {float} second(s)')
|
|
85
109
|
public async setBranchTTL (seconds: number): Promise<void> {
|
|
86
110
|
process.env.__TESTING_EXPOSITION_BRANCH_TTL = String(seconds * 1000)
|
|
@@ -116,6 +140,11 @@ export class Gateway {
|
|
|
116
140
|
public async cleanup (): Promise<void> {
|
|
117
141
|
delete process.env.__TESTING_EXPOSITION_BRANCH_TTL
|
|
118
142
|
|
|
143
|
+
for (const key of this.written)
|
|
144
|
+
delete process.env[key]
|
|
145
|
+
|
|
146
|
+
this.written = []
|
|
147
|
+
|
|
119
148
|
if (this.default)
|
|
120
149
|
return
|
|
121
150
|
|
|
@@ -138,10 +167,14 @@ export class Gateway {
|
|
|
138
167
|
|
|
139
168
|
process.env[key] ??= JSON.stringify(configuration)
|
|
140
169
|
}
|
|
170
|
+
|
|
171
|
+
for (const [name, value] of Object.entries(DEFAULT_SECRETS))
|
|
172
|
+
process.env[SECRET + name] ??= value
|
|
141
173
|
}
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
const EXPOSITION = '@toa.io/extensions.exposition'
|
|
177
|
+
const SECRET = 'TOA_CONFIGURATION__'
|
|
145
178
|
|
|
146
179
|
const DEFAULT_TREE = JSON.stringify({
|
|
147
180
|
routes: [],
|
|
@@ -161,18 +194,23 @@ const DEFAULT_PROPERTIES: Partial<http.Options> = {
|
|
|
161
194
|
}
|
|
162
195
|
}
|
|
163
196
|
|
|
197
|
+
// the identity components boot inside the gateway, and without a variable each would wait
|
|
198
|
+
// for the values service, which these features do not run
|
|
164
199
|
const DEFAULT_CONFIGURATION: Record<string, object> = {
|
|
200
|
+
'identity.basic': {},
|
|
201
|
+
'identity.federation': {},
|
|
202
|
+
'identity.otp': {},
|
|
203
|
+
'identity.passkeys': {},
|
|
165
204
|
'identity.tokens': {
|
|
166
205
|
keys: [
|
|
167
|
-
{
|
|
168
|
-
|
|
169
|
-
key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs'
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
id: 'legacy0',
|
|
173
|
-
key: 'k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA',
|
|
174
|
-
format: 'paseto'
|
|
175
|
-
}
|
|
206
|
+
{ id: 'key0', key: '$IDENTITY_TOKENS_KEY0' },
|
|
207
|
+
{ id: 'legacy0', key: '$IDENTITY_TOKENS_LEGACY0', format: 'paseto' }
|
|
176
208
|
]
|
|
177
209
|
}
|
|
178
210
|
}
|
|
211
|
+
|
|
212
|
+
// a secret is given as a reference, and comes to the component as an object
|
|
213
|
+
const DEFAULT_SECRETS: Record<string, string> = {
|
|
214
|
+
IDENTITY_TOKENS_KEY0: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs',
|
|
215
|
+
IDENTITY_TOKENS_LEGACY0: 'k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA'
|
|
216
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toa.io/extensions.exposition",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.274",
|
|
4
4
|
"description": "Toa Exposition",
|
|
5
5
|
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/toa-io/toa#readme",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"@simplewebauthn/server": "13.3.3",
|
|
21
21
|
"@toa.io/core": "1.0.0-alpha.273",
|
|
22
22
|
"@toa.io/generic": "1.0.0-alpha.273",
|
|
23
|
-
"@toa.io/schemas": "1.0.0-alpha.
|
|
23
|
+
"@toa.io/schemas": "1.0.0-alpha.274",
|
|
24
24
|
"bcryptjs": "3.0.3",
|
|
25
25
|
"content-type": "2.1.0",
|
|
26
26
|
"jose": "6.2.10",
|
|
@@ -61,5 +61,5 @@
|
|
|
61
61
|
},
|
|
62
62
|
"testEnvironment": "node"
|
|
63
63
|
},
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "7df32eb2872155c45681c299036b1efbeefd6518"
|
|
65
65
|
}
|
|
@@ -9,3 +9,9 @@ it('encodes error values as their public properties', () => {
|
|
|
9
9
|
expect(encode(value).toString())
|
|
10
10
|
.toBe('code: INVALID_USERNAME\nmessage: Username is not meeting the requirements\n')
|
|
11
11
|
})
|
|
12
|
+
|
|
13
|
+
it('writes a value the way its toJSON says', () => {
|
|
14
|
+
const secret = { unwrap: () => 's3cret', toJSON: () => '<REDACTED>' }
|
|
15
|
+
|
|
16
|
+
expect(encode({ a: 1, b: secret }).toString()).toBe('a: 1\nb: <REDACTED>\n')
|
|
17
|
+
})
|
|
@@ -10,8 +10,28 @@ export function decode (buffer: Buffer, charset = 'utf-8'): any {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function encode (value: any): Buffer {
|
|
13
|
-
const serializable = value instanceof Error ? Object.assign({}, value) : value
|
|
13
|
+
const serializable = value instanceof Error ? Object.assign({}, value) : represent(value)
|
|
14
14
|
const text = yaml.dump(serializable, { lineWidth: -1, noRefs: true })
|
|
15
15
|
|
|
16
16
|
return Buffer.from(text)
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/** What says how it is to be written — a redacted secret — is written that way, as in JSON. */
|
|
20
|
+
function represent (value: unknown): unknown {
|
|
21
|
+
if (typeof value !== 'object' || value === null || value instanceof Date || Buffer.isBuffer(value))
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
if ('toJSON' in value && typeof value.toJSON === 'function')
|
|
25
|
+
return value.toJSON()
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.map(represent)
|
|
29
|
+
|
|
30
|
+
const object = value as Record<string, unknown>
|
|
31
|
+
const represented: Record<string, unknown> = {}
|
|
32
|
+
|
|
33
|
+
for (const key of Object.keys(object))
|
|
34
|
+
represented[key] = represent(object[key])
|
|
35
|
+
|
|
36
|
+
return represented
|
|
37
|
+
}
|