@toa.io/extensions.exposition 0.20.0-dev.8 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/context.toa.yaml +15 -0
- package/components/identity.bans/manifest.toa.yaml +18 -0
- package/components/identity.basic/events/principal.js +9 -0
- package/components/identity.basic/manifest.toa.yaml +50 -0
- package/components/identity.basic/source/authenticate.ts +29 -0
- package/components/identity.basic/source/create.ts +19 -0
- package/components/identity.basic/source/transit.ts +64 -0
- package/components/identity.basic/source/types.ts +42 -0
- package/components/identity.basic/tsconfig.json +9 -0
- package/components/identity.roles/manifest.toa.yaml +31 -0
- package/components/identity.roles/source/list.ts +7 -0
- package/components/identity.roles/source/principal.ts +20 -0
- package/components/identity.roles/tsconfig.json +9 -0
- package/components/identity.tokens/manifest.toa.yaml +39 -0
- package/components/identity.tokens/receivers/identity.bans.updated.js +3 -0
- package/components/identity.tokens/source/authenticate.test.ts +56 -0
- package/components/identity.tokens/source/authenticate.ts +38 -0
- package/components/identity.tokens/source/decrypt.test.ts +59 -0
- package/components/identity.tokens/source/decrypt.ts +25 -0
- package/components/identity.tokens/source/encrypt.test.ts +35 -0
- package/components/identity.tokens/source/encrypt.ts +25 -0
- package/components/identity.tokens/source/revoke.ts +5 -0
- package/components/identity.tokens/source/types.ts +48 -0
- package/components/identity.tokens/tsconfig.json +9 -0
- package/cucumber.js +9 -0
- package/documentation/.assets/ia3-dark.jpg +0 -0
- package/documentation/.assets/ia3-light.jpg +0 -0
- package/documentation/.assets/overview-dark.jpg +0 -0
- package/documentation/.assets/overview-light.jpg +0 -0
- package/documentation/.assets/role-scopes-dark.jpg +0 -0
- package/documentation/.assets/role-scopes-light.jpg +0 -0
- package/documentation/.assets/rtd-dark.jpg +0 -0
- package/documentation/.assets/rtd-light.jpg +0 -0
- package/documentation/access.md +256 -0
- package/documentation/components.md +276 -0
- package/documentation/identity.md +156 -0
- package/documentation/notes/sse.md +71 -0
- package/documentation/protocol.md +18 -0
- package/documentation/query.md +226 -0
- package/documentation/tree.md +169 -0
- package/features/access.feature +448 -0
- package/features/annotation.feature +30 -0
- package/features/body.feature +45 -0
- package/features/directives.feature +56 -0
- package/features/dynamic.feature +99 -0
- package/features/errors.feature +193 -0
- package/features/identity.basic.feature +276 -0
- package/features/identity.feature +61 -0
- package/features/identity.roles.feature +51 -0
- package/features/identity.tokens.feature +119 -0
- package/features/queries.feature +214 -0
- package/features/routes.feature +49 -0
- package/features/steps/Common.ts +10 -0
- package/features/steps/Components.ts +43 -0
- package/features/steps/Database.ts +58 -0
- package/features/steps/Gateway.ts +113 -0
- package/features/steps/HTTP.ts +71 -0
- package/features/steps/Parameters.ts +12 -0
- package/features/steps/Workspace.ts +40 -0
- package/features/steps/components/echo/manifest.toa.yaml +9 -0
- package/features/steps/components/echo/operations/affect.js +7 -0
- package/features/steps/components/echo/operations/compute.js +7 -0
- package/features/steps/components/greeter/manifest.toa.yaml +5 -0
- package/features/steps/components/greeter/operations/greet.js +7 -0
- package/features/steps/components/pots/manifest.toa.yaml +20 -0
- package/features/steps/components/sequences/manifest.toa.yaml +10 -0
- package/features/steps/components/sequences/operations/numbers.js +7 -0
- package/features/steps/components/sequences/operations/tokens.js +16 -0
- package/features/steps/components/users/manifest.toa.yaml +11 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/streams.feature +26 -0
- package/package.json +32 -17
- package/readme.md +183 -0
- package/schemas/annotation.cos.yaml +5 -0
- package/schemas/directive.cos.yaml +3 -0
- package/schemas/method.cos.yaml +8 -0
- package/schemas/node.cos.yaml +5 -0
- package/schemas/query.cos.yaml +17 -0
- package/schemas/querystring.cos.yaml +5 -0
- package/schemas/range.cos.yaml +2 -0
- package/schemas/route.cos.yaml +2 -0
- package/source/Annotation.ts +7 -0
- package/source/Branch.ts +8 -0
- package/source/Composition.ts +57 -0
- package/source/Context.ts +6 -0
- package/source/Directive.test.ts +91 -0
- package/source/Directive.ts +120 -0
- package/source/Endpoint.ts +59 -0
- package/source/Factory.ts +51 -0
- package/source/Gateway.ts +93 -0
- package/source/HTTP/Server.fixtures.ts +45 -0
- package/source/HTTP/Server.test.ts +221 -0
- package/source/HTTP/Server.ts +135 -0
- package/source/HTTP/exceptions.ts +77 -0
- package/source/HTTP/formats/index.ts +19 -0
- package/source/HTTP/formats/json.ts +13 -0
- package/source/HTTP/formats/msgpack.ts +10 -0
- package/source/HTTP/formats/text.ts +9 -0
- package/source/HTTP/formats/yaml.ts +14 -0
- package/source/HTTP/index.ts +3 -0
- package/source/HTTP/messages.test.ts +116 -0
- package/source/HTTP/messages.ts +89 -0
- package/source/Mapping.ts +51 -0
- package/source/Query.test.ts +37 -0
- package/source/Query.ts +105 -0
- package/source/RTD/Context.ts +16 -0
- package/source/RTD/Directives.ts +9 -0
- package/source/RTD/Endpoint.ts +11 -0
- package/source/RTD/Match.ts +16 -0
- package/source/RTD/Method.ts +24 -0
- package/source/RTD/Node.ts +85 -0
- package/source/RTD/Route.ts +59 -0
- package/source/RTD/Tree.ts +54 -0
- package/source/RTD/factory.ts +47 -0
- package/source/RTD/index.ts +8 -0
- package/source/RTD/segment.test.ts +32 -0
- package/source/RTD/segment.ts +25 -0
- package/source/RTD/syntax/index.ts +2 -0
- package/source/RTD/syntax/parse.test.ts +188 -0
- package/source/RTD/syntax/parse.ts +153 -0
- package/source/RTD/syntax/types.ts +48 -0
- package/source/Remotes.test.ts +42 -0
- package/source/Remotes.ts +22 -0
- package/source/Tenant.ts +38 -0
- package/source/deployment.ts +49 -0
- package/source/directives/auth/Anonymous.ts +14 -0
- package/source/directives/auth/Echo.ts +12 -0
- package/source/directives/auth/Family.ts +145 -0
- package/source/directives/auth/Id.ts +19 -0
- package/source/directives/auth/Incept.ts +42 -0
- package/source/directives/auth/Role.test.ts +62 -0
- package/source/directives/auth/Role.ts +56 -0
- package/source/directives/auth/Rule.ts +28 -0
- package/source/directives/auth/Scheme.ts +26 -0
- package/source/directives/auth/index.ts +3 -0
- package/source/directives/auth/schemes.ts +8 -0
- package/source/directives/auth/split.ts +15 -0
- package/source/directives/auth/types.ts +37 -0
- package/source/directives/dev/Family.ts +36 -0
- package/source/directives/dev/Stub.ts +14 -0
- package/source/directives/dev/Throw.ts +14 -0
- package/source/directives/dev/index.ts +3 -0
- package/source/directives/dev/types.ts +5 -0
- package/source/directives/index.ts +5 -0
- package/source/discovery.ts +1 -0
- package/source/exceptions.ts +17 -0
- package/source/index.test.ts +9 -0
- package/source/index.ts +6 -0
- package/source/manifest.test.ts +59 -0
- package/source/manifest.ts +48 -0
- package/source/root.ts +38 -0
- package/source/schemas.ts +9 -0
- package/tsconfig.json +12 -0
- package/src/.manifest/index.js +0 -7
- package/src/.manifest/normalize.js +0 -58
- package/src/.manifest/schema.yaml +0 -71
- package/src/.manifest/validate.js +0 -17
- package/src/constants.js +0 -3
- package/src/deployment.js +0 -23
- package/src/exposition.js +0 -68
- package/src/factory.js +0 -76
- package/src/index.js +0 -9
- package/src/manifest.js +0 -12
- package/src/query/criteria.js +0 -55
- package/src/query/enum.js +0 -35
- package/src/query/index.js +0 -17
- package/src/query/query.js +0 -60
- package/src/query/range.js +0 -28
- package/src/query/sort.js +0 -19
- package/src/remote.js +0 -88
- package/src/server.js +0 -83
- package/src/tenant.js +0 -29
- package/src/translate/etag.js +0 -14
- package/src/translate/index.js +0 -7
- package/src/translate/request.js +0 -68
- package/src/translate/response.js +0 -62
- package/src/tree.js +0 -109
- package/test/manifest.normalize.fixtures.js +0 -37
- package/test/manifest.normalize.test.js +0 -37
- package/test/manifest.validate.test.js +0 -40
- package/test/query.range.test.js +0 -18
- package/test/tree.fixtures.js +0 -21
- package/test/tree.test.js +0 -44
- package/types/annotations.d.ts +0 -10
- package/types/declarations.d.ts +0 -31
- package/types/exposition.d.ts +0 -13
- package/types/http.d.ts +0 -13
- package/types/query.d.ts +0 -16
- package/types/remote.d.ts +0 -19
- package/types/server.d.ts +0 -13
- package/types/tree.d.ts +0 -33
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# used for development only
|
|
2
|
+
|
|
3
|
+
name: exposition
|
|
4
|
+
packages: '*'
|
|
5
|
+
registry: localhost
|
|
6
|
+
|
|
7
|
+
amqp: amqp://localhost
|
|
8
|
+
mongodb: mongodb://localhost
|
|
9
|
+
|
|
10
|
+
configuration:
|
|
11
|
+
identity.tokens:
|
|
12
|
+
key0: k3.local.pIZT8-9Fa6U_QtfQHOSStfGtmyzPINyKQq2Xk-hd7vA
|
|
13
|
+
|
|
14
|
+
exposition:
|
|
15
|
+
debug: true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
namespace: identity
|
|
2
|
+
name: basic
|
|
3
|
+
|
|
4
|
+
entity:
|
|
5
|
+
schema:
|
|
6
|
+
username*: string
|
|
7
|
+
password*: string
|
|
8
|
+
initialized: true
|
|
9
|
+
|
|
10
|
+
operations:
|
|
11
|
+
transit:
|
|
12
|
+
concurrency: retry
|
|
13
|
+
input:
|
|
14
|
+
username: string
|
|
15
|
+
password: string
|
|
16
|
+
incept:
|
|
17
|
+
forward: transit
|
|
18
|
+
query: false
|
|
19
|
+
input:
|
|
20
|
+
username*: string
|
|
21
|
+
password*: string
|
|
22
|
+
create:
|
|
23
|
+
input:
|
|
24
|
+
id*: string
|
|
25
|
+
credentials*: string
|
|
26
|
+
output:
|
|
27
|
+
id: string
|
|
28
|
+
authenticate:
|
|
29
|
+
input: string
|
|
30
|
+
output:
|
|
31
|
+
identity:
|
|
32
|
+
id: string
|
|
33
|
+
|
|
34
|
+
configuration:
|
|
35
|
+
rounds: 10
|
|
36
|
+
pepper: ''
|
|
37
|
+
principal: string
|
|
38
|
+
username+: ^\S{1,16}$
|
|
39
|
+
password+: ^\S{8,32}$
|
|
40
|
+
|
|
41
|
+
exposition:
|
|
42
|
+
isolated: true
|
|
43
|
+
/:
|
|
44
|
+
anonymous: true
|
|
45
|
+
POST: incept
|
|
46
|
+
/:id:
|
|
47
|
+
auth:role: system:identity:basic
|
|
48
|
+
auth:scheme: basic
|
|
49
|
+
auth:id: id
|
|
50
|
+
PATCH: transit
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { atob } from 'buffer'
|
|
2
|
+
import { compare } from 'bcryptjs'
|
|
3
|
+
import { type Query } from '@toa.io/types'
|
|
4
|
+
import { Nope, type Nopeable } from 'nopeable'
|
|
5
|
+
import { type Context } from './types'
|
|
6
|
+
|
|
7
|
+
export async function computation (input: string, context: Context): Promise<Nopeable<Output>> {
|
|
8
|
+
const [username, password] = atob(input).split(':')
|
|
9
|
+
const query: Query = { criteria: `username==${username}` }
|
|
10
|
+
const credentials = await context.local.observe({ query })
|
|
11
|
+
|
|
12
|
+
if (credentials instanceof Nope)
|
|
13
|
+
return credentials
|
|
14
|
+
|
|
15
|
+
if (credentials === null)
|
|
16
|
+
return new Nope('NOT_FOUND')
|
|
17
|
+
|
|
18
|
+
const spicy = password + context.configuration.pepper
|
|
19
|
+
const match = await compare(spicy, credentials.password)
|
|
20
|
+
|
|
21
|
+
if (match) return { identity: { id: credentials.id } }
|
|
22
|
+
else return new Nope('PASSWORD_MISMATCH')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Output {
|
|
26
|
+
identity: {
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Nopeable } from 'nopeable'
|
|
2
|
+
import { type Context } from './types'
|
|
3
|
+
|
|
4
|
+
export async function effect
|
|
5
|
+
(input: CreateInput, context: Context): Promise<Nopeable<CreateOutput>> {
|
|
6
|
+
const [username, password] = atob(input.credentials).split(':')
|
|
7
|
+
const request = { input: { username, password }, query: { id: input.id } }
|
|
8
|
+
|
|
9
|
+
return await context.local.transit(request)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CreateInput {
|
|
13
|
+
id: string
|
|
14
|
+
credentials: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CreateOutput {
|
|
18
|
+
id: string
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { genSalt, hash } from 'bcryptjs'
|
|
2
|
+
import { type Operation } from '@toa.io/types'
|
|
3
|
+
import { Nope, type Nopeable } from 'nopeable'
|
|
4
|
+
import { type Context, type Entity, type TransitInput, type TransitOutput } from './types'
|
|
5
|
+
|
|
6
|
+
export class Transition implements Operation {
|
|
7
|
+
private rounds: number = 10
|
|
8
|
+
private pepper: string = ''
|
|
9
|
+
private principal?: string
|
|
10
|
+
private tokens: Tokens = undefined as unknown as Tokens
|
|
11
|
+
private usernameRx: RegExp[] = []
|
|
12
|
+
private passwrodRx: RegExp[] = []
|
|
13
|
+
|
|
14
|
+
public mount (context: Context): void {
|
|
15
|
+
this.rounds = context.configuration.rounds
|
|
16
|
+
this.pepper = context.configuration.pepper
|
|
17
|
+
this.principal = context.configuration.principal
|
|
18
|
+
this.tokens = context.remote.identity.tokens
|
|
19
|
+
|
|
20
|
+
this.usernameRx = toRx(context.configuration.username)
|
|
21
|
+
this.passwrodRx = toRx(context.configuration.password)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public async execute (input: TransitInput, object: Entity): Promise<Nopeable<TransitOutput>> {
|
|
25
|
+
const existent = object._version !== 0
|
|
26
|
+
|
|
27
|
+
if (existent)
|
|
28
|
+
await this.tokens.revoke({ query: { id: object.id } })
|
|
29
|
+
|
|
30
|
+
if (input.username !== undefined) {
|
|
31
|
+
if (existent && object.username === this.principal)
|
|
32
|
+
return new Nope('PRINCIPAL_LOCKED', 'Principal username cannot be changed.')
|
|
33
|
+
|
|
34
|
+
if (invalid(input.username, this.usernameRx))
|
|
35
|
+
return new Nope('INVALID_USERNAME', 'Username is not meeting the requirements.')
|
|
36
|
+
|
|
37
|
+
object.username = input.username
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (input.password !== undefined) {
|
|
41
|
+
if (invalid(input.password, this.passwrodRx))
|
|
42
|
+
return new Nope('INVALID_PASSWORD', 'Password is not meeting the requirements.')
|
|
43
|
+
|
|
44
|
+
const salt = await genSalt(this.rounds)
|
|
45
|
+
const spicy = input.password + this.pepper
|
|
46
|
+
|
|
47
|
+
object.password = await hash(spicy, salt)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { id: object.id }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toRx (input: string | string[]): RegExp[] {
|
|
55
|
+
const expressions = typeof input === 'string' ? [input] : input
|
|
56
|
+
|
|
57
|
+
return expressions.map((expression) => new RegExp(expression))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invalid (value: string, expressions: RegExp[]): boolean {
|
|
61
|
+
return expressions.some((expression) => !expression.test(value))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Tokens = Context['remote']['identity']['tokens']
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Call, type Observation, type Query } from '@toa.io/types'
|
|
2
|
+
|
|
3
|
+
export interface Context {
|
|
4
|
+
local: {
|
|
5
|
+
observe: Observation<Entity>
|
|
6
|
+
transit: Call<TransitOutput, TransitInput>
|
|
7
|
+
}
|
|
8
|
+
remote: {
|
|
9
|
+
identity: {
|
|
10
|
+
tokens: {
|
|
11
|
+
revoke: Call<void, IdentityTokensRevokeInput>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
configuration: {
|
|
16
|
+
readonly rounds: number
|
|
17
|
+
readonly pepper: string
|
|
18
|
+
readonly principal?: string
|
|
19
|
+
readonly username: string | string[]
|
|
20
|
+
readonly password: string | string[]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Entity {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly _version: number
|
|
27
|
+
username: string
|
|
28
|
+
password: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TransitInput {
|
|
32
|
+
username?: string
|
|
33
|
+
password?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TransitOutput {
|
|
37
|
+
id: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface IdentityTokensRevokeInput {
|
|
41
|
+
query: Query
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
namespace: identity
|
|
2
|
+
name: roles
|
|
3
|
+
|
|
4
|
+
entity:
|
|
5
|
+
schema:
|
|
6
|
+
identity*: string
|
|
7
|
+
role*: string
|
|
8
|
+
|
|
9
|
+
operations:
|
|
10
|
+
transit:
|
|
11
|
+
query: false
|
|
12
|
+
input:
|
|
13
|
+
identity*: string
|
|
14
|
+
role*: string
|
|
15
|
+
list:
|
|
16
|
+
output: [string]
|
|
17
|
+
principal:
|
|
18
|
+
input:
|
|
19
|
+
id: string
|
|
20
|
+
|
|
21
|
+
receivers:
|
|
22
|
+
identity.basic.principal: principal
|
|
23
|
+
|
|
24
|
+
exposition:
|
|
25
|
+
isolated: true
|
|
26
|
+
/:identity:
|
|
27
|
+
auth:role: system:identity:roles
|
|
28
|
+
POST: transit
|
|
29
|
+
GET:
|
|
30
|
+
auth:id: identity
|
|
31
|
+
endpoint: list
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Call } from '@toa.io/types'
|
|
2
|
+
|
|
3
|
+
export async function effect (input: Identity, context: Context): Promise<void> {
|
|
4
|
+
await context.local.transit({ input: { identity: input.id, role: 'system' } })
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface Identity {
|
|
8
|
+
id: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Context {
|
|
12
|
+
local: {
|
|
13
|
+
transit: Call<void, TransitInput>
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TransitInput {
|
|
18
|
+
identity: string
|
|
19
|
+
role: string
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
namespace: identity
|
|
2
|
+
name: tokens
|
|
3
|
+
|
|
4
|
+
entity:
|
|
5
|
+
schema:
|
|
6
|
+
revokedAt: number # timestamp
|
|
7
|
+
initialized: true
|
|
8
|
+
|
|
9
|
+
operations:
|
|
10
|
+
encrypt:
|
|
11
|
+
input:
|
|
12
|
+
identity*: &identity
|
|
13
|
+
id: string
|
|
14
|
+
...: true
|
|
15
|
+
lifetime: number # seconds
|
|
16
|
+
output: string
|
|
17
|
+
decrypt:
|
|
18
|
+
input: string
|
|
19
|
+
output:
|
|
20
|
+
identity: *identity
|
|
21
|
+
iat: string
|
|
22
|
+
exp: string
|
|
23
|
+
refresh: boolean
|
|
24
|
+
authenticate:
|
|
25
|
+
input: string
|
|
26
|
+
output:
|
|
27
|
+
identity: *identity
|
|
28
|
+
refresh: boolean
|
|
29
|
+
revoke:
|
|
30
|
+
concurrency: retry
|
|
31
|
+
|
|
32
|
+
receivers:
|
|
33
|
+
identity.bans.updated: revoke
|
|
34
|
+
|
|
35
|
+
configuration:
|
|
36
|
+
key0*: string
|
|
37
|
+
key1: string
|
|
38
|
+
lifetime: 2592000 # seconds, 30 days
|
|
39
|
+
refresh: 600 # seconds, 10 minutes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { generate } from 'randomstring'
|
|
2
|
+
import { type Configuration, type Context, type DecryptOutput, type Identity } from './types'
|
|
3
|
+
import { Computation as Authenticate } from './authenticate'
|
|
4
|
+
|
|
5
|
+
let configuration: Configuration
|
|
6
|
+
let context: Context
|
|
7
|
+
let output: DecryptOutput
|
|
8
|
+
let authenticate: Authenticate
|
|
9
|
+
|
|
10
|
+
const identity: Identity = { id: generate() }
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
configuration = {
|
|
14
|
+
key0: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
|
|
15
|
+
lifetime: 2592000,
|
|
16
|
+
refresh: 600
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
context = {
|
|
20
|
+
configuration,
|
|
21
|
+
local: {
|
|
22
|
+
decrypt: jest.fn(async () => (output)),
|
|
23
|
+
observe: jest.fn(async () => null)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
authenticate = new Authenticate()
|
|
28
|
+
authenticate.mount(context)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
[true, -50],
|
|
33
|
+
[false, +50]
|
|
34
|
+
])('should mark as stale: %s', async (expected: boolean, shift: number) => {
|
|
35
|
+
const now = Date.now()
|
|
36
|
+
const iat = new Date(now - configuration.refresh * 1000 + shift).toISOString()
|
|
37
|
+
const exp = new Date(now + 1000).toISOString()
|
|
38
|
+
|
|
39
|
+
output = { identity, exp, iat, refresh: false }
|
|
40
|
+
|
|
41
|
+
const result = await authenticate.execute('')
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ identity, refresh: expected })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it.each([true, false])('should return stale: %s',
|
|
47
|
+
async (refresh) => {
|
|
48
|
+
const iat = new Date().toISOString()
|
|
49
|
+
const exp = new Date(Date.now() + 1000).toISOString()
|
|
50
|
+
|
|
51
|
+
output = { identity, exp, iat, refresh }
|
|
52
|
+
|
|
53
|
+
const result = await authenticate.execute('')
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ identity, refresh })
|
|
56
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Nope, type Nopeable } from 'nopeable'
|
|
2
|
+
import { type Operation } from '@toa.io/types'
|
|
3
|
+
import { type AuthenticateOutput, type Context } from './types'
|
|
4
|
+
|
|
5
|
+
export class Computation implements Operation {
|
|
6
|
+
private refresh: number = 0
|
|
7
|
+
private decrypt: Context['local']['decrypt'] = undefined as unknown as Context['local']['decrypt']
|
|
8
|
+
private observe: Context['local']['observe'] = undefined as unknown as Context['local']['observe']
|
|
9
|
+
|
|
10
|
+
public mount (context: Context): void {
|
|
11
|
+
this.refresh = context.configuration.refresh * 1000
|
|
12
|
+
this.decrypt = context.local.decrypt
|
|
13
|
+
this.observe = context.local.observe
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public async execute (token: string): Promise<Nopeable<AuthenticateOutput>> {
|
|
17
|
+
const claim = await this.decrypt({ input: token })
|
|
18
|
+
|
|
19
|
+
if (claim instanceof Nope)
|
|
20
|
+
return claim
|
|
21
|
+
|
|
22
|
+
const identity = claim.identity
|
|
23
|
+
const iat = new Date(claim.iat).getTime()
|
|
24
|
+
const transient = claim.exp !== undefined
|
|
25
|
+
const stale = transient && (iat + this.refresh < Date.now())
|
|
26
|
+
|
|
27
|
+
if (stale) {
|
|
28
|
+
const revocation = await this.observe({ query: { id: identity.id } })
|
|
29
|
+
|
|
30
|
+
if (revocation !== null && iat < revocation.revokedAt)
|
|
31
|
+
return new Nope('REVOKED')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const refresh = stale || claim.refresh
|
|
35
|
+
|
|
36
|
+
return { identity, refresh }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { generate } from 'randomstring'
|
|
2
|
+
import { Effect as Encrypt } from './encrypt'
|
|
3
|
+
import { computation as decrypt } from './decrypt'
|
|
4
|
+
import { type Configuration, type Context, type Identity } from './types'
|
|
5
|
+
|
|
6
|
+
let configuration: Configuration
|
|
7
|
+
let context: Context
|
|
8
|
+
let encrypt: Encrypt
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
configuration = {
|
|
12
|
+
key0: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
|
|
13
|
+
key1: 'k3.local.-498jfWenrZH-Dqw3-zQJih_hKzDgBgUMfe37OCqSOA',
|
|
14
|
+
lifetime: 1000,
|
|
15
|
+
refresh: 500
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
context = { configuration } as unknown as Context
|
|
19
|
+
|
|
20
|
+
encrypt = new Encrypt()
|
|
21
|
+
encrypt.mount(context)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should decrypt', async () => {
|
|
25
|
+
const identity: Identity = { id: generate() }
|
|
26
|
+
const lifetime = 100
|
|
27
|
+
|
|
28
|
+
const reply = await encrypt.execute({ identity, lifetime })
|
|
29
|
+
|
|
30
|
+
if (reply === undefined)
|
|
31
|
+
throw new Error('?')
|
|
32
|
+
|
|
33
|
+
const decrypted = await decrypt(reply, context)
|
|
34
|
+
|
|
35
|
+
expect(decrypted).toMatchObject({ identity, refresh: false })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should decrypt with key1', async () => {
|
|
39
|
+
const k1context = {
|
|
40
|
+
configuration: {
|
|
41
|
+
key0: configuration.key1
|
|
42
|
+
}
|
|
43
|
+
} as unknown as Context
|
|
44
|
+
|
|
45
|
+
encrypt = new Encrypt()
|
|
46
|
+
encrypt.mount(k1context)
|
|
47
|
+
|
|
48
|
+
const identity: Identity = { id: generate() }
|
|
49
|
+
const lifetime = 100
|
|
50
|
+
|
|
51
|
+
const encrypted = await encrypt.execute({ identity, lifetime })
|
|
52
|
+
|
|
53
|
+
if (encrypted === undefined)
|
|
54
|
+
throw new Error('?')
|
|
55
|
+
|
|
56
|
+
const decrypted = await decrypt(encrypted, context)
|
|
57
|
+
|
|
58
|
+
expect(decrypted).toMatchObject({ identity, refresh: true })
|
|
59
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { V3 } from 'paseto'
|
|
2
|
+
import { Nope, type Nopeable } from 'nopeable'
|
|
3
|
+
import { type Context, type Claim, type DecryptOutput } from './types'
|
|
4
|
+
|
|
5
|
+
export async function computation (token: string, context: Context):
|
|
6
|
+
Promise<Nopeable<DecryptOutput>> {
|
|
7
|
+
let refresh = false
|
|
8
|
+
let claim = await decrypt(token, context.configuration.key0)
|
|
9
|
+
|
|
10
|
+
if (claim === null && context.configuration.key1 !== undefined) {
|
|
11
|
+
refresh = true
|
|
12
|
+
claim = await decrypt(token, context.configuration.key1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (claim === null) return new Nope('INVALID_TOKEN', 'Invalid token')
|
|
16
|
+
else return { identity: claim.identity, iat: claim.iat, exp: claim.exp, refresh }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function decrypt (token: string, key: string): Promise<Claim | null> {
|
|
20
|
+
try {
|
|
21
|
+
return await V3.decrypt<Claim>(token, key)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { generate } from 'randomstring'
|
|
2
|
+
import { timeout } from '@toa.io/generic'
|
|
3
|
+
import { Effect as Encrypt } from './encrypt'
|
|
4
|
+
import { computation as decrypt } from './decrypt'
|
|
5
|
+
import { type Context, type Identity } from './types'
|
|
6
|
+
|
|
7
|
+
let encrypt: Encrypt
|
|
8
|
+
|
|
9
|
+
const context: Context = {} as unknown as Context
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
context.configuration = {
|
|
13
|
+
key0: 'k3.local.m28p8SrbS467t-2IUjQuSOqmjvi24TbXhyjAW_dOrog',
|
|
14
|
+
lifetime: 1000,
|
|
15
|
+
refresh: 2
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
encrypt = new Encrypt()
|
|
19
|
+
encrypt.mount(context)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should encrypt with given lifetime', async () => {
|
|
23
|
+
const identity: Identity = { id: generate() }
|
|
24
|
+
const lifetime = 0.1
|
|
25
|
+
const encrypted = await encrypt.execute({ identity, lifetime })
|
|
26
|
+
|
|
27
|
+
if (encrypted === undefined)
|
|
28
|
+
throw new Error('?')
|
|
29
|
+
|
|
30
|
+
await expect(decrypt(encrypted, context)).resolves.toMatchObject({ identity })
|
|
31
|
+
|
|
32
|
+
await timeout(lifetime * 1000)
|
|
33
|
+
|
|
34
|
+
await expect(decrypt(encrypted, context)).resolves.toMatchObject({ code: 'INVALID_TOKEN' })
|
|
35
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { V3 } from 'paseto'
|
|
2
|
+
import { type Operation } from '@toa.io/types'
|
|
3
|
+
import { type Claim, type Context, type EncryptInput } from './types'
|
|
4
|
+
|
|
5
|
+
export class Effect implements Operation {
|
|
6
|
+
private key: string = ''
|
|
7
|
+
private lifetime: number = 0
|
|
8
|
+
|
|
9
|
+
public mount (context: Context): void {
|
|
10
|
+
this.key = context.configuration.key0
|
|
11
|
+
this.lifetime = context.configuration.lifetime * 1000
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public async execute (input: EncryptInput): Promise<string> {
|
|
15
|
+
const lifetime = input.lifetime === undefined ? this.lifetime : input.lifetime * 1000
|
|
16
|
+
|
|
17
|
+
const exp = lifetime === 0
|
|
18
|
+
? undefined
|
|
19
|
+
: new Date(Date.now() + lifetime).toISOString()
|
|
20
|
+
|
|
21
|
+
const payload: Partial<Claim> = { identity: input.identity, exp }
|
|
22
|
+
|
|
23
|
+
return await V3.encrypt(payload, this.key)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Call, type Observation } from '@toa.io/types'
|
|
2
|
+
|
|
3
|
+
export interface Context {
|
|
4
|
+
local: {
|
|
5
|
+
observe: Observation<Entity>
|
|
6
|
+
decrypt: Call<DecryptOutput, string>
|
|
7
|
+
}
|
|
8
|
+
configuration: Configuration
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Configuration {
|
|
12
|
+
readonly key0: string
|
|
13
|
+
readonly key1?: string
|
|
14
|
+
readonly lifetime: number
|
|
15
|
+
readonly refresh: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Entity {
|
|
19
|
+
identity: string
|
|
20
|
+
revokedAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Identity extends Record<string, any> {
|
|
24
|
+
id: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuthenticateOutput {
|
|
28
|
+
identity: Identity
|
|
29
|
+
refresh: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EncryptInput {
|
|
33
|
+
identity: Identity
|
|
34
|
+
lifetime?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DecryptOutput {
|
|
38
|
+
identity: Identity
|
|
39
|
+
iat: string
|
|
40
|
+
exp?: string
|
|
41
|
+
refresh: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Claim {
|
|
45
|
+
identity: Identity
|
|
46
|
+
iat: string
|
|
47
|
+
exp?: string
|
|
48
|
+
}
|