@toa.io/extensions.exposition 1.0.0-alpha.253 → 1.0.0-alpha.256
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 +11 -0
- package/components/identity.bans/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.basic/manifest.toa.yaml +1 -1
- package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/manifest.toa.yaml +4 -4
- package/components/identity.federation/operations/authenticate.js +2 -1
- package/components/identity.federation/operations/authenticate.js.map +1 -1
- package/components/identity.federation/operations/decode.js +2 -1
- package/components/identity.federation/operations/decode.js.map +1 -1
- package/components/identity.federation/operations/delete.d.ts +2 -2
- package/components/identity.federation/operations/delete.js +3 -3
- package/components/identity.federation/operations/delete.js.map +1 -1
- package/components/identity.federation/operations/incept.js +2 -1
- package/components/identity.federation/operations/incept.js.map +1 -1
- package/components/identity.federation/operations/lib/Ctx.d.ts +2 -0
- package/components/identity.federation/operations/lib/decode.js +4 -27
- package/components/identity.federation/operations/lib/decode.js.map +1 -1
- package/components/identity.federation/operations/lib/discovery.d.ts +4 -3
- package/components/identity.federation/operations/lib/discovery.js +21 -31
- package/components/identity.federation/operations/lib/discovery.js.map +1 -1
- package/components/identity.federation/operations/lib/exchange.js +7 -6
- package/components/identity.federation/operations/lib/exchange.js.map +1 -1
- package/components/identity.federation/operations/lib/jose.d.ts +41 -0
- package/components/identity.federation/operations/lib/jose.js +27 -0
- package/components/identity.federation/operations/lib/jose.js.map +1 -0
- package/components/identity.federation/operations/list.js +2 -1
- package/components/identity.federation/operations/list.js.map +1 -1
- package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.federation/operations/types/context.d.ts +2 -0
- package/components/identity.federation/source/authenticate.ts +2 -1
- package/components/identity.federation/source/decode.ts +2 -1
- package/components/identity.federation/source/delete.ts +4 -4
- package/components/identity.federation/source/incept.ts +2 -1
- package/components/identity.federation/source/lib/Ctx.ts +2 -0
- package/components/identity.federation/source/lib/decode.ts +4 -5
- package/components/identity.federation/source/lib/discovery.test.ts +45 -0
- package/components/identity.federation/source/lib/discovery.ts +27 -8
- package/components/identity.federation/source/lib/exchange.ts +7 -7
- package/components/identity.federation/source/lib/jose.d.ts +1 -0
- package/components/identity.federation/source/lib/jose.js +5 -0
- package/components/identity.federation/source/list.test.ts +2 -1
- package/components/identity.federation/source/list.ts +2 -1
- package/components/identity.federation/source/types/context.ts +3 -0
- package/components/identity.federation/tsconfig.json +1 -4
- package/components/identity.keys/manifest.toa.yaml +1 -0
- package/components/identity.keys/operations/create.js +2 -2
- package/components/identity.keys/operations/create.js.map +1 -1
- package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.keys/source/create.ts +2 -2
- 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/manifest.toa.yaml +21 -4
- package/components/identity.tokens/operations/decrypt.d.ts +4 -2
- package/components/identity.tokens/operations/decrypt.js +60 -12
- package/components/identity.tokens/operations/decrypt.js.map +1 -1
- package/components/identity.tokens/operations/encrypt.js +14 -13
- package/components/identity.tokens/operations/encrypt.js.map +1 -1
- package/components/identity.tokens/operations/lib/index.d.ts +1 -0
- package/components/identity.tokens/operations/lib/index.js +3 -0
- package/components/identity.tokens/operations/lib/index.js.map +1 -1
- package/components/identity.tokens/operations/lib/jose.d.ts +41 -0
- package/components/identity.tokens/operations/lib/jose.js +27 -0
- package/components/identity.tokens/operations/lib/jose.js.map +1 -0
- package/components/identity.tokens/operations/lib/key.d.ts +1 -0
- package/components/identity.tokens/operations/lib/key.js +11 -0
- package/components/identity.tokens/operations/lib/key.js.map +1 -0
- package/components/identity.tokens/operations/lib/types.d.ts +10 -1
- package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
- package/components/identity.tokens/source/authenticate.test.ts +4 -3
- package/components/identity.tokens/source/decrypt.test.ts +69 -8
- package/components/identity.tokens/source/decrypt.ts +72 -14
- package/components/identity.tokens/source/encrypt.test.ts +30 -3
- package/components/identity.tokens/source/encrypt.ts +18 -16
- package/components/identity.tokens/source/lib/index.ts +1 -0
- package/components/identity.tokens/source/lib/jose.d.ts +1 -0
- package/components/identity.tokens/source/lib/jose.js +5 -0
- package/components/identity.tokens/source/lib/key.ts +8 -0
- package/components/identity.tokens/source/lib/types.ts +12 -1
- package/{components/context.toa.yaml → context.toa.yaml} +6 -2
- package/documentation/authorities.md +1 -1
- package/documentation/components.md +70 -30
- package/documentation/identity.md +12 -14
- package/features/access.feature +1 -1
- package/features/cache.feature +5 -5
- package/features/cors.feature +4 -4
- package/features/credentials.feature +3 -3
- package/features/identity.basic.feature +1 -1
- package/features/identity.federation.feature +4 -5
- package/features/identity.roles.feature +1 -1
- package/features/identity.tokens.feature +3 -3
- package/features/steps/Gateway.ts +11 -3
- package/package.json +18 -21
- package/source/HTTP/formats/yaml.test.ts +9 -0
- package/source/HTTP/formats/yaml.ts +2 -1
- package/source/HTTP/messages.ts +1 -1
- package/source/directives/io/lib/throttle/Interval.ts +10 -1
- package/transpiled/HTTP/formats/yaml.js +2 -1
- package/transpiled/HTTP/formats/yaml.js.map +1 -1
- package/transpiled/HTTP/messages.js +24 -4
- package/transpiled/HTTP/messages.js.map +1 -1
- package/transpiled/directives/io/lib/throttle/Interval.d.ts +1 -0
- package/transpiled/directives/io/lib/throttle/Interval.js +9 -1
- package/transpiled/directives/io/lib/throttle/Interval.js.map +1 -1
- package/transpiled/tsconfig.tsbuildinfo +1 -1
|
@@ -12,9 +12,10 @@ const authority = generate()
|
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
configuration = {
|
|
15
|
-
keys:
|
|
16
|
-
key0: '
|
|
17
|
-
|
|
15
|
+
keys: [
|
|
16
|
+
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
17
|
+
{ id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
|
|
18
|
+
],
|
|
18
19
|
lifetime: 2592000,
|
|
19
20
|
refresh: 600,
|
|
20
21
|
cache: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generate } from 'randomstring'
|
|
2
|
+
import { V3 } from 'paseto'
|
|
2
3
|
import { Effect as Encrypt } from './encrypt'
|
|
3
4
|
import { Computation as Decrypt } from './decrypt'
|
|
4
5
|
import { type Configuration, type Context, type Identity } from './lib'
|
|
@@ -8,15 +9,17 @@ let context: Context
|
|
|
8
9
|
let encrypt: Encrypt
|
|
9
10
|
let decrypt: Decrypt
|
|
10
11
|
|
|
11
|
-
const remote = { identity: { keys: null } }
|
|
12
|
+
const remote = { identity: { keys: { observe: jest.fn(async () => null) } } }
|
|
12
13
|
const authority = generate()
|
|
13
14
|
|
|
14
15
|
beforeEach(() => {
|
|
15
16
|
configuration = {
|
|
16
|
-
keys:
|
|
17
|
-
key0: '
|
|
18
|
-
key1: '
|
|
19
|
-
|
|
17
|
+
keys: [
|
|
18
|
+
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
19
|
+
{ id: 'key1', key: '5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0' },
|
|
20
|
+
{ id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' },
|
|
21
|
+
{ id: 'legacy1', key: 'k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA', format: 'paseto' }
|
|
22
|
+
],
|
|
20
23
|
lifetime: 1000,
|
|
21
24
|
refresh: 500,
|
|
22
25
|
cache: {
|
|
@@ -51,9 +54,7 @@ it('should decrypt', async () => {
|
|
|
51
54
|
it('should decrypt with key1', async () => {
|
|
52
55
|
const k1context = {
|
|
53
56
|
configuration: {
|
|
54
|
-
keys:
|
|
55
|
-
key1: configuration.keys.key1
|
|
56
|
-
}
|
|
57
|
+
keys: [configuration.keys[1]]
|
|
57
58
|
}
|
|
58
59
|
} as unknown as Context
|
|
59
60
|
|
|
@@ -72,3 +73,63 @@ it('should decrypt with key1', async () => {
|
|
|
72
73
|
|
|
73
74
|
expect(decrypted).toMatchObject({ identity, refresh: true })
|
|
74
75
|
})
|
|
76
|
+
|
|
77
|
+
it('should decrypt legacy PASETO and require refresh', async () => {
|
|
78
|
+
const identity: Identity = { id: generate(), roles: [] }
|
|
79
|
+
|
|
80
|
+
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
|
|
81
|
+
footer: { kid: 'legacy0' }
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await expect(decrypt.execute(token)).resolves.toMatchObject({
|
|
85
|
+
iss: authority,
|
|
86
|
+
identity,
|
|
87
|
+
refresh: true
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should separate JWE and PASETO keys with the same id by format', async () => {
|
|
92
|
+
configuration.keys[2].id = 'key0'
|
|
93
|
+
|
|
94
|
+
decrypt = new Decrypt()
|
|
95
|
+
decrypt.mount(context)
|
|
96
|
+
|
|
97
|
+
const identity: Identity = { id: generate(), roles: [] }
|
|
98
|
+
|
|
99
|
+
const token = await V3.encrypt({ iss: authority, identity }, configuration.keys[2].key, {
|
|
100
|
+
footer: { kid: 'key0' }
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await expect(decrypt.execute(token)).resolves.toMatchObject({ identity, refresh: true })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should reject a tampered JWE', async () => {
|
|
107
|
+
const token = await encrypt.execute({
|
|
108
|
+
authority,
|
|
109
|
+
identity: { id: generate(), roles: [] }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (token instanceof Error)
|
|
113
|
+
throw token
|
|
114
|
+
|
|
115
|
+
const parts = token.split('.')
|
|
116
|
+
|
|
117
|
+
parts[3] = (parts[3].startsWith('A') ? 'B' : 'A') + parts[3].slice(1)
|
|
118
|
+
|
|
119
|
+
const tampered = parts.join('.')
|
|
120
|
+
|
|
121
|
+
await expect(decrypt.execute(tampered)).resolves.toMatchObject({ code: 'INVALID_TOKEN' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should reject JWE with an unknown key', async () => {
|
|
125
|
+
const token = await encrypt.execute({
|
|
126
|
+
authority,
|
|
127
|
+
identity: { id: generate(), roles: [] },
|
|
128
|
+
key: { id: 'missing', key: configuration.keys[0].key, label: 'missing' }
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (token instanceof Error)
|
|
132
|
+
throw token
|
|
133
|
+
|
|
134
|
+
await expect(decrypt.execute(token)).resolves.toMatchObject({ code: 'INVALID_KEY' })
|
|
135
|
+
})
|
|
@@ -1,38 +1,52 @@
|
|
|
1
1
|
import { V3 } from 'paseto'
|
|
2
2
|
import { Err } from 'error-value'
|
|
3
3
|
import { LRUCache } from 'lru-cache'
|
|
4
|
+
import { jweKey } from './lib'
|
|
5
|
+
import { load } from './lib/jose'
|
|
4
6
|
import type { Maybe, Operation } from '@toa.io/types'
|
|
5
|
-
import type { Context, Claims, DecryptOutput } from './lib'
|
|
7
|
+
import type { Context, Claims, DecryptOutput, JWEClaims } from './lib'
|
|
6
8
|
|
|
7
9
|
export class Computation implements Operation {
|
|
8
|
-
private keys: Record<string, Key> = {}
|
|
10
|
+
private readonly keys: Record<string, Key> = {}
|
|
11
|
+
private readonly legacy: Record<string, Key> = {}
|
|
9
12
|
private cache!: LRUCache<string, KeyEntry>
|
|
10
13
|
private latest!: string
|
|
11
14
|
private remote!: Context['remote']['identity']['keys']
|
|
12
15
|
private logs!: Context['logs']
|
|
13
16
|
|
|
14
17
|
public mount (context: Context): void {
|
|
15
|
-
|
|
18
|
+
const latest = context.configuration.keys.find(({ format }) => format !== 'paseto')
|
|
19
|
+
|
|
20
|
+
if (latest === undefined)
|
|
21
|
+
throw new TypeError('At least one JWE key must be configured')
|
|
22
|
+
|
|
23
|
+
this.latest = latest.id
|
|
16
24
|
this.remote = context.remote.identity.keys
|
|
17
25
|
this.cache = new LRUCache<string, KeyEntry>(context.configuration.cache)
|
|
18
26
|
this.logs = context.logs
|
|
19
27
|
|
|
20
|
-
for (const
|
|
21
|
-
|
|
28
|
+
for (const { id, key, format } of context.configuration.keys) {
|
|
29
|
+
const branch = format === 'paseto' ? this.legacy : this.keys
|
|
30
|
+
|
|
31
|
+
branch[id] = { key }
|
|
32
|
+
}
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
public async execute (token: string): Promise<Maybe<DecryptOutput>> {
|
|
25
|
-
const
|
|
36
|
+
const legacy = token.startsWith('v3.local.')
|
|
37
|
+
const kid = legacy ? this.pasetoKid(token) : await this.jweKid(token)
|
|
26
38
|
|
|
27
39
|
if (kid instanceof Error)
|
|
28
40
|
return kid
|
|
29
41
|
|
|
30
|
-
const key = await this.key(kid)
|
|
42
|
+
const key = await this.key(kid, legacy)
|
|
31
43
|
|
|
32
44
|
if (key instanceof Error)
|
|
33
45
|
return key
|
|
34
46
|
|
|
35
|
-
const claims =
|
|
47
|
+
const claims = legacy
|
|
48
|
+
? await decryptPaseto(token, key.key)
|
|
49
|
+
: await decryptJWE(token, key.key)
|
|
36
50
|
|
|
37
51
|
if (claims instanceof Error)
|
|
38
52
|
return claims
|
|
@@ -47,11 +61,11 @@ export class Computation implements Operation {
|
|
|
47
61
|
iat: claims.iat,
|
|
48
62
|
exp: claims.exp,
|
|
49
63
|
identity: claims.identity,
|
|
50
|
-
refresh: kid !== this.latest && key.identity === undefined
|
|
64
|
+
refresh: legacy || (kid !== this.latest && key.identity === undefined)
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
private
|
|
68
|
+
private pasetoKid (token: string): Maybe<string> {
|
|
55
69
|
const [, , , footer] = token.split('.')
|
|
56
70
|
|
|
57
71
|
if (footer === undefined)
|
|
@@ -70,9 +84,26 @@ export class Computation implements Operation {
|
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
private async
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
private async jweKid (token: string): Promise<Maybe<string>> {
|
|
88
|
+
try {
|
|
89
|
+
const { decodeProtectedHeader } = await load()
|
|
90
|
+
const header = decodeProtectedHeader(token)
|
|
91
|
+
|
|
92
|
+
if (header.alg !== 'dir' || header.enc !== 'A256GCM' || header.typ !== 'JWT' ||
|
|
93
|
+
typeof header.kid !== 'string')
|
|
94
|
+
return ERR_INVALID_TOKEN
|
|
95
|
+
|
|
96
|
+
return header.kid
|
|
97
|
+
} catch {
|
|
98
|
+
return ERR_INVALID_TOKEN
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async key (kid: string, legacy: boolean): Promise<Maybe<Key>> {
|
|
103
|
+
const configured = legacy ? this.legacy : this.keys
|
|
104
|
+
|
|
105
|
+
if (kid in configured)
|
|
106
|
+
return configured[kid]
|
|
76
107
|
|
|
77
108
|
if (!this.cache.has(kid)) {
|
|
78
109
|
const value = await this.remote.observe({ query: { id: kid } })
|
|
@@ -86,7 +117,7 @@ export class Computation implements Operation {
|
|
|
86
117
|
}
|
|
87
118
|
}
|
|
88
119
|
|
|
89
|
-
async function
|
|
120
|
+
async function decryptPaseto (token: string, key: string): Promise<Maybe<Claims>> {
|
|
90
121
|
try {
|
|
91
122
|
return await V3.decrypt<Claims>(token, key)
|
|
92
123
|
} catch {
|
|
@@ -94,6 +125,33 @@ async function decrypt (token: string, key: string): Promise<Maybe<Claims>> {
|
|
|
94
125
|
}
|
|
95
126
|
}
|
|
96
127
|
|
|
128
|
+
async function decryptJWE (token: string, key: string): Promise<Maybe<Claims>> {
|
|
129
|
+
try {
|
|
130
|
+
const { jwtDecrypt } = await load()
|
|
131
|
+
|
|
132
|
+
const { payload } = await jwtDecrypt<JWEClaims>(token, jweKey(key), {
|
|
133
|
+
keyManagementAlgorithms: ['dir'],
|
|
134
|
+
contentEncryptionAlgorithms: ['A256GCM']
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (typeof payload.iss !== 'string' || typeof payload.iat !== 'number' ||
|
|
138
|
+
typeof payload.identity !== 'object' || payload.identity === null)
|
|
139
|
+
return ERR_INVALID_TOKEN
|
|
140
|
+
|
|
141
|
+
if (payload.exp !== undefined && payload.exp * 1000 <= Date.now())
|
|
142
|
+
return ERR_INVALID_TOKEN
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
iss: payload.iss,
|
|
146
|
+
iat: new Date(payload.iat * 1000).toISOString(),
|
|
147
|
+
...(payload.exp === undefined ? {} : { exp: new Date(payload.exp * 1000).toISOString() }),
|
|
148
|
+
identity: payload.identity
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
return ERR_INVALID_TOKEN
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
97
155
|
interface Key {
|
|
98
156
|
key: string
|
|
99
157
|
identity?: string
|
|
@@ -17,9 +17,10 @@ const authority = generate()
|
|
|
17
17
|
|
|
18
18
|
beforeEach(() => {
|
|
19
19
|
context.configuration = {
|
|
20
|
-
keys:
|
|
21
|
-
key0: '
|
|
22
|
-
|
|
20
|
+
keys: [
|
|
21
|
+
{ id: 'key0', key: 'sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs' },
|
|
22
|
+
{ id: 'legacy0', key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog', format: 'paseto' }
|
|
23
|
+
],
|
|
23
24
|
lifetime: 1,
|
|
24
25
|
refresh: 2,
|
|
25
26
|
cache: {
|
|
@@ -35,6 +36,32 @@ beforeEach(() => {
|
|
|
35
36
|
decrypt.mount(context)
|
|
36
37
|
})
|
|
37
38
|
|
|
39
|
+
it('should use the first encryption key as active and expose its id as kid', async () => {
|
|
40
|
+
context.configuration.keys.unshift({
|
|
41
|
+
id: 'legacy-first',
|
|
42
|
+
key: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
|
|
43
|
+
format: 'paseto'
|
|
44
|
+
})
|
|
45
|
+
context.configuration.keys.push({
|
|
46
|
+
id: 'key1',
|
|
47
|
+
key: '5I0iSKw3yfBkQ4AXfA8eR-tWR0Q1dpn4x3bPrPzHkP0'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
encrypt.mount(context)
|
|
51
|
+
|
|
52
|
+
const encrypted = await encrypt.execute({
|
|
53
|
+
authority,
|
|
54
|
+
identity: { id: generate(), roles: [] }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (encrypted instanceof Error)
|
|
58
|
+
throw encrypted
|
|
59
|
+
|
|
60
|
+
const header = JSON.parse(Buffer.from(encrypted.split('.')[0], 'base64url').toString())
|
|
61
|
+
|
|
62
|
+
expect(header).toMatchObject({ kid: 'key0', alg: 'dir', enc: 'A256GCM' })
|
|
63
|
+
})
|
|
64
|
+
|
|
38
65
|
it('should encrypt with configured lifetime by default', async () => {
|
|
39
66
|
const identity: Identity = { id: generate(), roles: [] }
|
|
40
67
|
|
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
import { V3 } from 'paseto'
|
|
2
1
|
import { Err } from 'error-value'
|
|
2
|
+
import { jweKey } from './lib'
|
|
3
|
+
import { load } from './lib/jose'
|
|
3
4
|
import type { Operation, Maybe } from '@toa.io/types'
|
|
4
|
-
import type { Identity,
|
|
5
|
+
import type { Identity, Context, EncryptInput, Key } from './lib'
|
|
5
6
|
|
|
6
7
|
export class Effect implements Operation {
|
|
7
8
|
private key!: Pick<Key, 'id' | 'key'>
|
|
8
9
|
private lifetime!: number
|
|
9
10
|
|
|
10
11
|
public mount (context: Context): void {
|
|
11
|
-
const
|
|
12
|
+
const key = context.configuration.keys.find(({ format }) => format !== 'paseto')
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
if (key === undefined)
|
|
15
|
+
throw new TypeError('At least one JWE key must be configured')
|
|
16
|
+
|
|
17
|
+
this.key = key
|
|
14
18
|
this.lifetime = context.configuration.lifetime * 1000
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
public async execute (input: EncryptInput): Promise<Maybe<string>> {
|
|
22
|
+
const { EncryptJWT } = await load()
|
|
23
|
+
|
|
18
24
|
if (input.scopes?.some((scope) => !within(scope, input.identity.roles)) === true)
|
|
19
25
|
return ERR_INACCESSIBLE_SCOPE
|
|
20
26
|
|
|
21
27
|
const lifetime = input.lifetime === undefined ? this.lifetime : (input.lifetime * 1000)
|
|
22
28
|
|
|
23
|
-
const exp = lifetime === 0
|
|
24
|
-
? undefined
|
|
25
|
-
: new Date(Date.now() + lifetime).toISOString()
|
|
26
|
-
|
|
27
29
|
const identity: Identity = {
|
|
28
30
|
id: input.identity.id,
|
|
29
31
|
roles: input.scopes ?? input.identity.roles
|
|
@@ -32,17 +34,17 @@ export class Effect implements Operation {
|
|
|
32
34
|
if (input.permissions !== undefined)
|
|
33
35
|
identity.permissions = input.permissions
|
|
34
36
|
|
|
35
|
-
const
|
|
36
|
-
identity,
|
|
37
|
-
iss: input.authority
|
|
38
|
-
}
|
|
37
|
+
const key = input.key ?? this.key
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
let token = new EncryptJWT({ identity })
|
|
40
|
+
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT', kid: key.id })
|
|
41
|
+
.setIssuer(input.authority)
|
|
42
|
+
.setIssuedAt(Date.now() / 1000)
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
if (lifetime !== 0)
|
|
45
|
+
token = token.setExpirationTime((Date.now() + lifetime) / 1000)
|
|
44
46
|
|
|
45
|
-
return await
|
|
47
|
+
return await token.encrypt(jweKey(key.key))
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function load (): Promise<typeof import('jose')>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function jweKey (value: string): Uint8Array {
|
|
2
|
+
const key = Buffer.from(value, 'base64url')
|
|
3
|
+
|
|
4
|
+
if (key.length !== 32 || key.toString('base64url') !== value)
|
|
5
|
+
throw new TypeError('JWE key must be a base64url-encoded 256-bit secret')
|
|
6
|
+
|
|
7
|
+
return new Uint8Array(key)
|
|
8
|
+
}
|
|
@@ -22,7 +22,7 @@ export interface Context {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface Configuration {
|
|
25
|
-
readonly keys:
|
|
25
|
+
readonly keys: ConfiguredKey[]
|
|
26
26
|
readonly lifetime: number
|
|
27
27
|
readonly refresh: number
|
|
28
28
|
readonly cache: {
|
|
@@ -31,6 +31,10 @@ export interface Configuration {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface ConfiguredKey extends Pick<Key, 'id' | 'key'> {
|
|
35
|
+
format?: 'jwe' | 'paseto'
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export interface Entity {
|
|
35
39
|
revokedAt?: number
|
|
36
40
|
}
|
|
@@ -75,6 +79,13 @@ export interface Claims {
|
|
|
75
79
|
exp?: string
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
export interface JWEClaims {
|
|
83
|
+
identity: Identity
|
|
84
|
+
iss: string
|
|
85
|
+
iat: number
|
|
86
|
+
exp?: number
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
export interface Key {
|
|
79
90
|
id: string
|
|
80
91
|
key: string
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# used for development only
|
|
2
2
|
|
|
3
3
|
name: exposition
|
|
4
|
-
packages: '*'
|
|
5
4
|
registry: localhost
|
|
6
5
|
|
|
7
6
|
amqp: amqp://localhost
|
|
@@ -9,7 +8,12 @@ mongodb: mongodb://localhost
|
|
|
9
8
|
|
|
10
9
|
configuration:
|
|
11
10
|
identity.tokens:
|
|
12
|
-
|
|
11
|
+
keys:
|
|
12
|
+
- id: key0
|
|
13
|
+
key: sTxL6qVOadKkUJwh3FveU53XgTEo3Sdfg7k2FfiIKfs
|
|
14
|
+
- id: legacy0
|
|
15
|
+
key: k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA
|
|
16
|
+
format: paseto
|
|
13
17
|
|
|
14
18
|
identity.federation:
|
|
15
19
|
trust:
|
|
@@ -19,7 +19,7 @@ exposition:
|
|
|
19
19
|
|
|
20
20
|
## Mappings
|
|
21
21
|
|
|
22
|
-
To pass the requested authority to the operation call, [`map:authority` directive](map#
|
|
22
|
+
To pass the requested authority to the operation call, [`map:authority` directive](map.md#authority)
|
|
23
23
|
can be used.
|
|
24
24
|
|
|
25
25
|
```yaml
|
|
@@ -128,8 +128,10 @@ the [component manifest](../components/identity.federation/manifest.toa.yaml).
|
|
|
128
128
|
No federated tokens are accepted by default until at least one entry is added to the `trust`
|
|
129
129
|
configuration.
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
Issuer metadata is loaded from `<iss>/.well-known/openid-configuration`; signing keys are then
|
|
132
|
+
loaded from its `jwks_uri`. These requests, including Authorization Code token exchange, use the
|
|
133
|
+
built-in `context.fetch`, so they participate in standard HTTP telemetry and connection pooling.
|
|
134
|
+
JWKS resolvers are cached per fetch instance and issuer.
|
|
133
135
|
|
|
134
136
|
```yaml
|
|
135
137
|
# context.toa.yaml
|
|
@@ -142,10 +144,8 @@ configuration:
|
|
|
142
144
|
- https://github.com/tinovyatkin
|
|
143
145
|
- https://github.com/temich
|
|
144
146
|
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
HS256:
|
|
148
|
-
k1: <secret-to-be-used-for-hs256>
|
|
147
|
+
- iss: https://accounts.google.com
|
|
148
|
+
aud: <GOOGLE_CLIENT_ID>
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
## Local tokens
|
|
@@ -187,6 +187,7 @@ accept: application/yaml
|
|
|
187
187
|
content-type: application/yaml
|
|
188
188
|
|
|
189
189
|
lifetime: 3600
|
|
190
|
+
label: CI deployment token
|
|
190
191
|
scopes: [app:developer]
|
|
191
192
|
permissions:
|
|
192
193
|
/users/fc8e66dd/: [GET, PUT]
|
|
@@ -197,6 +198,8 @@ permissions:
|
|
|
197
198
|
201 Created
|
|
198
199
|
content-type: application/yaml
|
|
199
200
|
|
|
201
|
+
kid: <key-id>
|
|
202
|
+
exp: <unix-time-ms>
|
|
200
203
|
token: <token>
|
|
201
204
|
```
|
|
202
205
|
|
|
@@ -204,6 +207,7 @@ token: <token>
|
|
|
204
207
|
(default is specified in [the configuration](#token-rotation)).
|
|
205
208
|
The value of `0` means the token will not expire, which is supported, but
|
|
206
209
|
**strongly not recommended** for production environments.
|
|
210
|
+
- `label`: Required human-readable name used when listing and revoking issued tokens.
|
|
207
211
|
- `scopes`: Issued token will assume only specified [role scopes](access.md#roles).
|
|
208
212
|
- `permissions`: Issued token will have permissions to access only specified resources and methods.
|
|
209
213
|
Supports [glob patterns](https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html)
|
|
@@ -223,19 +227,35 @@ DELETE /identity/keys/<identity>/<key.id>/
|
|
|
223
227
|
authorization: ...
|
|
224
228
|
```
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
token itself
|
|
230
|
+
The key `id` is returned as `kid` when the token is issued, is visible in the JWE protected header,
|
|
231
|
+
and can also be obtained by listing issued token keys. The secret key itself is never returned by
|
|
232
|
+
the listing endpoint.
|
|
228
233
|
|
|
229
234
|
```
|
|
230
235
|
GET /identity/keys/<identity>/
|
|
231
236
|
authorization: ...
|
|
232
237
|
```
|
|
233
238
|
|
|
239
|
+
The listing returns `id`, `label`, optional `expires`, and `_created`. Deletion prevents new cache
|
|
240
|
+
lookups from finding the key. A runtime that already cached it can continue accepting the token for
|
|
241
|
+
up to `identity.tokens.cache.ttl` milliseconds (10 minutes by default).
|
|
242
|
+
|
|
243
|
+
Both listing and deletion require credentials of the owning Identity or the
|
|
244
|
+
`system:identity:keys` role. Key creation is internal to `identity.tokens`; there is no public
|
|
245
|
+
endpoint that returns a stored secret.
|
|
246
|
+
|
|
234
247
|
### Token encryption
|
|
235
248
|
|
|
236
|
-
Issued tokens are encrypted
|
|
237
|
-
|
|
238
|
-
|
|
249
|
+
Issued tokens are compact JWE tokens encrypted with direct symmetric encryption. Their protected
|
|
250
|
+
header is `{ alg: 'dir', enc: 'A256GCM', typ: 'JWT', kid: <key id> }`. Keys are configured in one
|
|
251
|
+
`keys` array. The first entry whose `format` is `jwe` or omitted is the active issuance key.
|
|
252
|
+
Keys are 256-bit secrets encoded with base64url and can be generated with `toa key`.
|
|
253
|
+
|
|
254
|
+
The optional `format` is `jwe` by default. Entries with `format: paseto` contain legacy PASETO
|
|
255
|
+
V3.local secrets and are used only to read old tokens. Legacy tokens are always marked for refresh;
|
|
256
|
+
only JWE tokens are issued.
|
|
257
|
+
The `format` property is transitional and can be removed together with the PASETO entries after the
|
|
258
|
+
legacy compatibility window ends.
|
|
239
259
|
|
|
240
260
|
```yaml
|
|
241
261
|
# context.toa.yaml
|
|
@@ -243,10 +263,15 @@ using the first key from the `keys` configuration value.
|
|
|
243
263
|
configuration:
|
|
244
264
|
identity.tokens:
|
|
245
265
|
keys:
|
|
246
|
-
|
|
266
|
+
- id: 2026q3
|
|
267
|
+
key: $IDENTITY_TOKENS_ENCRYPTION_KEY0
|
|
268
|
+
- id: legacy
|
|
269
|
+
key: $IDENTITY_TOKENS_KEY0
|
|
270
|
+
format: paseto
|
|
247
271
|
```
|
|
248
272
|
|
|
249
|
-
At least one
|
|
273
|
+
At least one JWE entry is required. PASETO entries are required only while previously issued legacy
|
|
274
|
+
tokens must remain readable.
|
|
250
275
|
|
|
251
276
|
> Valid secret key may be generated using the [`toa key` command](/runtime/cli/readme.md#key).
|
|
252
277
|
|
|
@@ -279,48 +304,63 @@ configuration:
|
|
|
279
304
|
|
|
280
305
|
All currently issued tokens of an Identity are revoked when:
|
|
281
306
|
|
|
282
|
-
1. Basic credentials associated with the Identity are
|
|
307
|
+
1. [Basic credentials](#basic-credentials) associated with the Identity are modified.
|
|
283
308
|
2. Identity is [banned](#banned-identities).
|
|
284
309
|
|
|
285
310
|
Token revocation takes effect once the `refresh` period of the currently issued tokens has expired.
|
|
286
311
|
|
|
287
312
|
### Secret rotation
|
|
288
313
|
|
|
289
|
-
Tokens are always encrypted using the first
|
|
290
|
-
|
|
314
|
+
Tokens are always encrypted using the first JWE entry of the `keys` array and decrypted by the
|
|
315
|
+
entry in the corresponding format branch whose `id` matches the token's `kid`.
|
|
291
316
|
|
|
292
|
-
|
|
293
|
-
|
|
317
|
+
Rotation uses array order rather than object property order: index `0` is active for issuance and
|
|
318
|
+
the remaining entries are decrypt-only. Use the staged procedure below so every runtime knows the
|
|
319
|
+
new key before it becomes active.
|
|
294
320
|
|
|
295
|
-
Old keys must
|
|
296
|
-
|
|
321
|
+
Old keys must remain configured until every token issued with them has expired. In the default
|
|
322
|
+
configuration this means retaining them for at least `lifetime` (30 days) after making a new key
|
|
323
|
+
active. The shorter `refresh` interval only controls replacement during active client use and is
|
|
324
|
+
not a safe removal window for clients that remain idle.
|
|
297
325
|
|
|
298
326
|
> Let's say you are adding a new secret key each quarter: `2024Q1`, `2024Q2` and so on.
|
|
299
|
-
> The old key `2024Q1` must be removed
|
|
300
|
-
>
|
|
327
|
+
> The old key `2024Q1` must be removed only after the maximum lifetime of tokens issued with it has
|
|
328
|
+
> elapsed.
|
|
301
329
|
|
|
302
330
|
```yaml
|
|
303
331
|
# context.toa.yaml
|
|
304
332
|
|
|
305
333
|
configuration:
|
|
306
334
|
identity.tokens:
|
|
307
|
-
|
|
308
|
-
|
|
335
|
+
keys:
|
|
336
|
+
- id: 2026q3
|
|
337
|
+
key: $TOKEN_ENCRYPTION_KEY_2026Q3
|
|
338
|
+
- id: 2026q4
|
|
339
|
+
key: $TOKEN_ENCRYPTION_KEY_2026Q4
|
|
309
340
|
```
|
|
310
341
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
342
|
+
1. First deploy both keys everywhere with the current key at index `0`, as shown above.
|
|
343
|
+
2. In an atomic rollout, move the new entry to index `0` and keep the current entry after it.
|
|
344
|
+
All instances can then decrypt tokens encrypted with both the new key and the current key.
|
|
314
345
|
|
|
315
346
|
```yaml
|
|
316
347
|
# context.toa.yaml
|
|
317
348
|
|
|
318
349
|
configuration:
|
|
319
350
|
identity.tokens:
|
|
320
|
-
|
|
321
|
-
|
|
351
|
+
keys:
|
|
352
|
+
- id: 2026q4
|
|
353
|
+
key: $TOKEN_ENCRYPTION_KEY_2026Q4
|
|
354
|
+
- id: 2026q3
|
|
355
|
+
key: $TOKEN_ENCRYPTION_KEY_2026Q3
|
|
322
356
|
```
|
|
323
357
|
|
|
358
|
+
3. Remove `2026q3` only after its last possible token has expired.
|
|
359
|
+
|
|
360
|
+
The PASETO-to-JWE runtime migration must also be atomic: legacy runtimes cannot decrypt newly
|
|
361
|
+
issued JWE tokens. Keep every `format: paseto` entry until its last token has expired;
|
|
362
|
+
`toa key --format paseto` exists only for maintaining those legacy secrets.
|
|
363
|
+
|
|
324
364
|
### Token resources
|
|
325
365
|
|
|
326
366
|
`/identity/tokens/`
|
|
@@ -361,7 +401,7 @@ Role Scopes (see [Role Hierarchies](access.md#hierarchies)).
|
|
|
361
401
|
|
|
362
402
|
The `identity.bans` component manages banned identities.
|
|
363
403
|
A banned identity will fail to authenticate with any associated credentials
|
|
364
|
-
(except [tokens](#
|
|
404
|
+
(except [tokens](#local-tokens) within the `refresh` period).
|
|
365
405
|
|
|
366
406
|
```http
|
|
367
407
|
PUT /identity/bans/:id/
|