@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/components/identity.bans/operations/tsconfig.tsbuildinfo +1 -1
  3. package/components/identity.basic/manifest.toa.yaml +1 -1
  4. package/components/identity.basic/operations/tsconfig.tsbuildinfo +1 -1
  5. package/components/identity.credentials/operations/tsconfig.tsbuildinfo +1 -1
  6. package/components/identity.federation/manifest.toa.yaml +4 -4
  7. package/components/identity.federation/operations/authenticate.js +2 -1
  8. package/components/identity.federation/operations/authenticate.js.map +1 -1
  9. package/components/identity.federation/operations/decode.js +2 -1
  10. package/components/identity.federation/operations/decode.js.map +1 -1
  11. package/components/identity.federation/operations/delete.d.ts +2 -2
  12. package/components/identity.federation/operations/delete.js +3 -3
  13. package/components/identity.federation/operations/delete.js.map +1 -1
  14. package/components/identity.federation/operations/incept.js +2 -1
  15. package/components/identity.federation/operations/incept.js.map +1 -1
  16. package/components/identity.federation/operations/lib/Ctx.d.ts +2 -0
  17. package/components/identity.federation/operations/lib/decode.js +4 -27
  18. package/components/identity.federation/operations/lib/decode.js.map +1 -1
  19. package/components/identity.federation/operations/lib/discovery.d.ts +4 -3
  20. package/components/identity.federation/operations/lib/discovery.js +21 -31
  21. package/components/identity.federation/operations/lib/discovery.js.map +1 -1
  22. package/components/identity.federation/operations/lib/exchange.js +7 -6
  23. package/components/identity.federation/operations/lib/exchange.js.map +1 -1
  24. package/components/identity.federation/operations/lib/jose.d.ts +41 -0
  25. package/components/identity.federation/operations/lib/jose.js +27 -0
  26. package/components/identity.federation/operations/lib/jose.js.map +1 -0
  27. package/components/identity.federation/operations/list.js +2 -1
  28. package/components/identity.federation/operations/list.js.map +1 -1
  29. package/components/identity.federation/operations/tsconfig.tsbuildinfo +1 -1
  30. package/components/identity.federation/operations/types/context.d.ts +2 -0
  31. package/components/identity.federation/source/authenticate.ts +2 -1
  32. package/components/identity.federation/source/decode.ts +2 -1
  33. package/components/identity.federation/source/delete.ts +4 -4
  34. package/components/identity.federation/source/incept.ts +2 -1
  35. package/components/identity.federation/source/lib/Ctx.ts +2 -0
  36. package/components/identity.federation/source/lib/decode.ts +4 -5
  37. package/components/identity.federation/source/lib/discovery.test.ts +45 -0
  38. package/components/identity.federation/source/lib/discovery.ts +27 -8
  39. package/components/identity.federation/source/lib/exchange.ts +7 -7
  40. package/components/identity.federation/source/lib/jose.d.ts +1 -0
  41. package/components/identity.federation/source/lib/jose.js +5 -0
  42. package/components/identity.federation/source/list.test.ts +2 -1
  43. package/components/identity.federation/source/list.ts +2 -1
  44. package/components/identity.federation/source/types/context.ts +3 -0
  45. package/components/identity.federation/tsconfig.json +1 -4
  46. package/components/identity.keys/manifest.toa.yaml +1 -0
  47. package/components/identity.keys/operations/create.js +2 -2
  48. package/components/identity.keys/operations/create.js.map +1 -1
  49. package/components/identity.keys/operations/tsconfig.tsbuildinfo +1 -1
  50. package/components/identity.keys/source/create.ts +2 -2
  51. package/components/identity.otp/operations/tsconfig.tsbuildinfo +1 -1
  52. package/components/identity.passkeys/operations/tsconfig.tsbuildinfo +1 -1
  53. package/components/identity.roles/operations/tsconfig.tsbuildinfo +1 -1
  54. package/components/identity.tokens/manifest.toa.yaml +21 -4
  55. package/components/identity.tokens/operations/decrypt.d.ts +4 -2
  56. package/components/identity.tokens/operations/decrypt.js +60 -12
  57. package/components/identity.tokens/operations/decrypt.js.map +1 -1
  58. package/components/identity.tokens/operations/encrypt.js +14 -13
  59. package/components/identity.tokens/operations/encrypt.js.map +1 -1
  60. package/components/identity.tokens/operations/lib/index.d.ts +1 -0
  61. package/components/identity.tokens/operations/lib/index.js +3 -0
  62. package/components/identity.tokens/operations/lib/index.js.map +1 -1
  63. package/components/identity.tokens/operations/lib/jose.d.ts +41 -0
  64. package/components/identity.tokens/operations/lib/jose.js +27 -0
  65. package/components/identity.tokens/operations/lib/jose.js.map +1 -0
  66. package/components/identity.tokens/operations/lib/key.d.ts +1 -0
  67. package/components/identity.tokens/operations/lib/key.js +11 -0
  68. package/components/identity.tokens/operations/lib/key.js.map +1 -0
  69. package/components/identity.tokens/operations/lib/types.d.ts +10 -1
  70. package/components/identity.tokens/operations/tsconfig.tsbuildinfo +1 -1
  71. package/components/identity.tokens/source/authenticate.test.ts +4 -3
  72. package/components/identity.tokens/source/decrypt.test.ts +69 -8
  73. package/components/identity.tokens/source/decrypt.ts +72 -14
  74. package/components/identity.tokens/source/encrypt.test.ts +30 -3
  75. package/components/identity.tokens/source/encrypt.ts +18 -16
  76. package/components/identity.tokens/source/lib/index.ts +1 -0
  77. package/components/identity.tokens/source/lib/jose.d.ts +1 -0
  78. package/components/identity.tokens/source/lib/jose.js +5 -0
  79. package/components/identity.tokens/source/lib/key.ts +8 -0
  80. package/components/identity.tokens/source/lib/types.ts +12 -1
  81. package/{components/context.toa.yaml → context.toa.yaml} +6 -2
  82. package/documentation/authorities.md +1 -1
  83. package/documentation/components.md +70 -30
  84. package/documentation/identity.md +12 -14
  85. package/features/access.feature +1 -1
  86. package/features/cache.feature +5 -5
  87. package/features/cors.feature +4 -4
  88. package/features/credentials.feature +3 -3
  89. package/features/identity.basic.feature +1 -1
  90. package/features/identity.federation.feature +4 -5
  91. package/features/identity.roles.feature +1 -1
  92. package/features/identity.tokens.feature +3 -3
  93. package/features/steps/Gateway.ts +11 -3
  94. package/package.json +18 -21
  95. package/source/HTTP/formats/yaml.test.ts +9 -0
  96. package/source/HTTP/formats/yaml.ts +2 -1
  97. package/source/HTTP/messages.ts +1 -1
  98. package/source/directives/io/lib/throttle/Interval.ts +10 -1
  99. package/transpiled/HTTP/formats/yaml.js +2 -1
  100. package/transpiled/HTTP/formats/yaml.js.map +1 -1
  101. package/transpiled/HTTP/messages.js +24 -4
  102. package/transpiled/HTTP/messages.js.map +1 -1
  103. package/transpiled/directives/io/lib/throttle/Interval.d.ts +1 -0
  104. package/transpiled/directives/io/lib/throttle/Interval.js +9 -1
  105. package/transpiled/directives/io/lib/throttle/Interval.js.map +1 -1
  106. 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: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'
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: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
18
- key1: 'k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA'
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
- this.latest = Object.keys(context.configuration.keys)[0]
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 [kid, key] of Object.entries(context.configuration.keys))
21
- this.keys[kid] = { key }
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 kid = this.kid(token)
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 = await decrypt(token, key.key)
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 kid (token: string): Maybe<string> {
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 key (kid: string): Promise<Maybe<Key>> {
74
- if (kid in this.keys)
75
- return this.keys[kid]
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 decrypt (token: string, key: string): Promise<Maybe<Claims>> {
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: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog'
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, Claims, Context, EncryptInput, Key } from './lib'
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 [id, secret] = Object.entries(context.configuration.keys)[0]
12
+ const key = context.configuration.keys.find(({ format }) => format !== 'paseto')
12
13
 
13
- this.key = { id, key: secret }
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 payload: Partial<Claims> = {
36
- identity,
37
- iss: input.authority
38
- }
37
+ const key = input.key ?? this.key
39
38
 
40
- if (exp !== undefined)
41
- payload.exp = exp
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
- const key = input.key ?? this.key
44
+ if (lifetime !== 0)
45
+ token = token.setExpirationTime((Date.now() + lifetime) / 1000)
44
46
 
45
- return await V3.encrypt(payload, key.key, { footer: { kid: key.id } })
47
+ return await token.encrypt(jweKey(key.key))
46
48
  }
47
49
  }
48
50
 
@@ -1,2 +1,3 @@
1
1
  export * from './pad'
2
2
  export * from './types'
3
+ export { jweKey } from './key'
@@ -0,0 +1 @@
1
+ export function load (): Promise<typeof import('jose')>
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ const load = async () => await import('jose')
4
+
5
+ exports.load = load
@@ -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: Record<string, string>
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
- key0: k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA
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#embeddings)
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
- Toa supports either asymmetric RS256 or symmetric HS256 / HS384 / HS512 tokens with pre-shared
132
- secrets.
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
- - issuer: some.private.issuer
146
- secrets:
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
- Token secret key `id` can be obtained from the list of issued tokens (or from the footer of the
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
- with [PASETO V3 encryption](https://github.com/panva/paseto/blob/main/docs/README.md#v3encryptpayload-key-options)
238
- using the first key from the `keys` configuration value.
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
- 2024q1: $TOKEN_SECRET_2024Q1
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 key in the `keys` configuration value is required.
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 [modified](#identitybasicid).
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 key from the `keys` configuration value,
290
- and decrypted by the key used to encrypt them.
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
- To rotate the secret key, a new key must be added to the top of the `keys` configuration value, that
293
- is, it will be used to encrypt new tokens.
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 be removed only after the `refresh` period of the previously issued tokens has
296
- expired.
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 from the configuration only when the `refresh` period after
300
- > the new key `2024Q2` was added has expired.
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
- key0: $TOKEN_ENCRYPTION_KEY_2023Q3
308
- key1: $TOKEN_ENCRYPTION_KEY_2023Q4
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
- 2. Move the new secret key from `key1` to `key0`, and move the current key from `key0` to `key1`.
312
- During this rollout,
313
- all instances can decrypt tokens encrypted with both the new key and the current key.
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
- key0: $TOKEN_ENCRYPTION_KEY_2023Q4
321
- key1: $TOKEN_ENCRYPTION_KEY_2023Q3
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](#stateless-tokens) within the `refresh` period).
404
+ (except [tokens](#local-tokens) within the `refresh` period).
365
405
 
366
406
  ```http
367
407
  PUT /identity/bans/:id/