@reventlessdev/reventless-aws 3.0.0-alpha.394 → 3.0.0-alpha.396
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 +22 -0
- package/package.json +8 -8
- package/scripts/DeployApp.res +7 -1
- package/scripts/DeployApp.res.mjs +1 -1
- package/scripts/ProvisionAccounts.res +89 -4
- package/scripts/ProvisionAccounts.res.mjs +101 -5
- package/scripts/ProvisionIdentity.res +42 -18
- package/scripts/ProvisionIdentity.res.mjs +40 -26
- package/src/Platform.res +47 -10
- package/src/Platform.res.mjs +44 -10
- package/src/Platform_Stack.res +17 -0
- package/src/Platform_Stack.res.mjs +7 -1
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +16 -7
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +16 -3
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res +59 -9
- package/src/adapter/Auth/Auth_ActiveRoleTrigger_Ops.res.mjs +53 -6
- package/src/adapter/Auth/Auth_Cognito.res +13 -2
- package/src/adapter/Auth/Auth_Cognito.res.mjs +9 -2
- package/src/adapter/Auth/Auth_IdentityStore.res +52 -0
- package/src/adapter/Auth/Auth_IdentityStore.res.mjs +46 -0
- package/src/adapter/Auth/Auth_IdentityStore_Schema.res +39 -0
- package/src/adapter/Auth/Auth_IdentityStore_Schema.res.mjs +35 -0
- package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res +251 -0
- package/src/adapter/Identity/IdentityProvider_Cognito_Backend.res.mjs +300 -0
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res +7 -4
- package/src/adapter/Runtime/AutomationSliceEntryPoint_Ops.res.mjs +2 -2
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +80 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +53 -2
- package/src/components/AutomationSlice_Builder.res.mjs +1 -1
- package/src/components/OutboundTranslationSlice_Builder.res +2 -0
- package/src/components/OutboundTranslationSlice_Builder.res.mjs +1 -1
- package/src/plugin/runtime/PluginRuntime_Builder.res +23 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +29 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res +40 -0
- package/tests/Auth_ActiveRoleTrigger_OpsTest.res.mjs +59 -8
- package/tests/Auth_CognitoTest.res +62 -0
- package/tests/Auth_CognitoTest.res.mjs +98 -0
- package/tests/Auth_IdentityStore_SchemaTest.res +35 -0
- package/tests/Auth_IdentityStore_SchemaTest.res.mjs +43 -0
- package/tests/IdentityProvider_Cognito_BackendTest.res +314 -0
- package/tests/IdentityProvider_Cognito_BackendTest.res.mjs +442 -0
- package/tests/ProvisionAccountsTest.res.mjs +1 -1
- package/tests/Util_ShellConfigTest.res.mjs +1 -22
|
@@ -58,13 +58,24 @@ let _getHeader = (headers: dict<string>, name: string): option<string> =>
|
|
|
58
58
|
| None => headers->Dict.get(name)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// The domain id when the token carries one, else the `sub`. The fallback is
|
|
62
|
+
// what every account created before the identity store existed relies on: its
|
|
63
|
+
// rows are keyed by the `sub`, and a different answer here would orphan them.
|
|
64
|
+
let _userIdOf = (~claims: dict<JSON.t>, ~sub: string): string =>
|
|
65
|
+
claims
|
|
66
|
+
->Dict.get(ReventlessCore.Auth_UserId.claim)
|
|
67
|
+
->Option.flatMap(JSON.Decode.string)
|
|
68
|
+
->Option.filter(id => id != "")
|
|
69
|
+
->Option.getOr(sub)
|
|
70
|
+
|
|
61
71
|
let _identityFromClaims = (claims: 'a): Identity.t => {
|
|
62
72
|
let claimsDict: dict<JSON.t> = claims->Obj.magic
|
|
63
|
-
let
|
|
73
|
+
let sub =
|
|
64
74
|
claimsDict
|
|
65
75
|
->Dict.get("sub")
|
|
66
76
|
->Option.flatMap(JSON.Decode.string)
|
|
67
77
|
->Option.getOr("anonymous")
|
|
78
|
+
let userId = _userIdOf(~claims=claimsDict, ~sub)
|
|
68
79
|
let username =
|
|
69
80
|
claimsDict
|
|
70
81
|
->Dict.get("cognito:username")
|
|
@@ -154,7 +165,7 @@ let fromAppSyncIdentity = (id: option<appSyncIdentity>): Identity.authResult =>
|
|
|
154
165
|
->Array.map(((k, v)) => (k, v->JSON.Decode.string->Option.getOr(v->JSON.stringify)))
|
|
155
166
|
->Dict.fromArray
|
|
156
167
|
Authenticated({
|
|
157
|
-
userId: sub,
|
|
168
|
+
userId: _userIdOf(~claims=rawClaims, ~sub),
|
|
158
169
|
username,
|
|
159
170
|
groups,
|
|
160
171
|
claims,
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Pulumi from "@pulumi/pulumi";
|
|
6
6
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
7
|
+
import * as Auth_UserId$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_UserId.res.mjs";
|
|
7
8
|
import * as Platform_Stack$ReventlessAws from "../../Platform_Stack.res.mjs";
|
|
8
9
|
|
|
9
10
|
let _decodeJwtClaims = (function(token) {
|
|
@@ -31,8 +32,13 @@ function _getHeader(headers, name) {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function _userIdOf(claims, sub) {
|
|
36
|
+
return Stdlib_Option.getOr(Stdlib_Option.filter(Stdlib_Option.flatMap(claims[Auth_UserId$ReventlessCore.claim], Stdlib_JSON.Decode.string), id => id !== ""), sub);
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
function _identityFromClaims(claims) {
|
|
35
|
-
let
|
|
40
|
+
let sub = Stdlib_Option.getOr(Stdlib_Option.flatMap(claims["sub"], Stdlib_JSON.Decode.string), "anonymous");
|
|
41
|
+
let userId = _userIdOf(claims, sub);
|
|
36
42
|
let username = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.orElse(claims["cognito:username"], claims["username"]), Stdlib_JSON.Decode.string), userId);
|
|
37
43
|
let groups = Stdlib_Option.getOr(Stdlib_Option.map(claims["cognito:groups"], g => g), []);
|
|
38
44
|
return {
|
|
@@ -81,7 +87,7 @@ function fromAppSyncIdentity(id) {
|
|
|
81
87
|
return {
|
|
82
88
|
TAG: "Authenticated",
|
|
83
89
|
_0: {
|
|
84
|
-
userId: sub,
|
|
90
|
+
userId: _userIdOf(rawClaims, sub),
|
|
85
91
|
username: username,
|
|
86
92
|
groups: groups,
|
|
87
93
|
claims: claims,
|
|
@@ -127,6 +133,7 @@ export {
|
|
|
127
133
|
_decodeJwtClaims,
|
|
128
134
|
_bearerToken,
|
|
129
135
|
_getHeader,
|
|
136
|
+
_userIdOf,
|
|
130
137
|
_identityFromClaims,
|
|
131
138
|
authenticate,
|
|
132
139
|
fromAppSyncIdentity,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The rows mapping a Cognito `sub` to the domain's own user id, and where they
|
|
2
|
+
// live. Read by the pre-token-generation trigger on every mint, written only
|
|
3
|
+
// when an account is created (`provision-accounts`, and the Cognito identity
|
|
4
|
+
// provider when it exists).
|
|
5
|
+
//
|
|
6
|
+
// 🚨 **The rows follow the identity provider, not the stack**, exactly as the
|
|
7
|
+
// active-role store's do and for the same reason: a pool holds one pre-token
|
|
8
|
+
// trigger, so two stacks sharing a pool with a table each would have the winning
|
|
9
|
+
// trigger reading rows the other stack wrote nowhere near. A stack that owns its
|
|
10
|
+
// pool owns this table; a stack handed an `identityProviderId` looks it up at
|
|
11
|
+
// [Auth_IdentityStore_Schema.derivedStoreName] and never creates it.
|
|
12
|
+
|
|
13
|
+
type storeTable = Auth_ActiveRoleStore.storeTable
|
|
14
|
+
|
|
15
|
+
/** Which store a deployment's trigger reads. Total and pure, like the active-role
|
|
16
|
+
store's: whether the provider is ours decides it, and nothing else. */
|
|
17
|
+
let chooseStore = (~identityProviderId: option<string>): Auth_ActiveRoleStore.storeChoice =>
|
|
18
|
+
switch identityProviderId {
|
|
19
|
+
| None => StackScoped
|
|
20
|
+
| Some(id) => ProviderScoped(Auth_IdentityStore_Schema.derivedStoreName(~identityProviderId=id))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One row per subject — the key schema is [Auth_IdentityStore_Schema]'s — plus
|
|
24
|
+
`userId`, the domain id minted for it. */
|
|
25
|
+
let makeTable = (
|
|
26
|
+
~name: string="IdentityStore",
|
|
27
|
+
~opts: Pulumi.ComponentResource.options,
|
|
28
|
+
): storeTable => {
|
|
29
|
+
let opts = opts->ReventlessCore.Util.Pulumi.ComponentResourceOptions.toCustomResourceOptions
|
|
30
|
+
let table = Util_DynamoDb.makeTable(
|
|
31
|
+
~attributes=[{name: Auth_IdentityStore_Schema.partitionKey, type_: "S"}],
|
|
32
|
+
~tags=AWS.Tags.make(
|
|
33
|
+
~name=name ++ "Table",
|
|
34
|
+
~kind=ReventlessCore.ComponentType.Platform,
|
|
35
|
+
~role=Auth,
|
|
36
|
+
~scope=Platform,
|
|
37
|
+
),
|
|
38
|
+
~opts,
|
|
39
|
+
name,
|
|
40
|
+
)
|
|
41
|
+
{name: table.name, arn: table.arn}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The store a resolved provider calls for — created or looked up. */
|
|
45
|
+
let resolveTable = (
|
|
46
|
+
~choice: Auth_ActiveRoleStore.storeChoice,
|
|
47
|
+
~opts: Pulumi.ComponentResource.options,
|
|
48
|
+
): storeTable =>
|
|
49
|
+
switch choice {
|
|
50
|
+
| StackScoped => makeTable(~opts)
|
|
51
|
+
| ProviderScoped(tableName) => Auth_ActiveRoleStore.lookupTable(~tableName)
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
|
|
4
|
+
import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
|
|
5
|
+
import * as Util_DynamoDb$ReventlessAws from "../../util/Util_DynamoDb.res.mjs";
|
|
6
|
+
import * as Auth_ActiveRoleStore$ReventlessAws from "./Auth_ActiveRoleStore.res.mjs";
|
|
7
|
+
import * as Auth_IdentityStore_Schema$ReventlessAws from "./Auth_IdentityStore_Schema.res.mjs";
|
|
8
|
+
|
|
9
|
+
function chooseStore(identityProviderId) {
|
|
10
|
+
if (identityProviderId !== undefined) {
|
|
11
|
+
return {
|
|
12
|
+
TAG: "ProviderScoped",
|
|
13
|
+
_0: Auth_IdentityStore_Schema$ReventlessAws.derivedStoreName(identityProviderId)
|
|
14
|
+
};
|
|
15
|
+
} else {
|
|
16
|
+
return "StackScoped";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeTable(nameOpt, opts) {
|
|
21
|
+
let name = nameOpt !== undefined ? nameOpt : "IdentityStore";
|
|
22
|
+
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
23
|
+
let table = Util_DynamoDb$ReventlessAws.makeTable([{
|
|
24
|
+
name: Auth_IdentityStore_Schema$ReventlessAws.partitionKey,
|
|
25
|
+
type: "S"
|
|
26
|
+
}], undefined, undefined, undefined, AWS_Tags$ReventlessAws.make(name + "Table", "Platform", "Auth", "Platform", undefined, undefined, undefined, undefined), opts$1, name);
|
|
27
|
+
return {
|
|
28
|
+
name: table.name,
|
|
29
|
+
arn: table.arn
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveTable(choice, opts) {
|
|
34
|
+
if (typeof choice !== "object") {
|
|
35
|
+
return makeTable(undefined, opts);
|
|
36
|
+
} else {
|
|
37
|
+
return Auth_ActiveRoleStore$ReventlessAws.lookupTable(choice._0);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
chooseStore,
|
|
43
|
+
makeTable,
|
|
44
|
+
resolveTable,
|
|
45
|
+
}
|
|
46
|
+
/* AWS_Tags-ReventlessAws Not a pure module */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The identity store's identity: what it is called, and how its rows are keyed.
|
|
2
|
+
// The mapping from a Cognito `sub` to the domain's own user id — see
|
|
3
|
+
// [ReventlessCore.Auth_UserId] for why the two differ.
|
|
4
|
+
//
|
|
5
|
+
// One definition for the deploy that creates or looks the table up
|
|
6
|
+
// ([Auth_IdentityStore]), the pre-token trigger that reads it
|
|
7
|
+
// ([Auth_ActiveRoleTrigger_Ops]), and the provisioning scripts that write it
|
|
8
|
+
// (`provision-identity`, `provision-accounts`). Free of Pulumi and of the AWS
|
|
9
|
+
// SDK for the reason [Auth_ActiveRoleStore_Schema] gives.
|
|
10
|
+
|
|
11
|
+
/** Derived from the provider, never configured, for the reason the active-role
|
|
12
|
+
store's name is: two stacks on one pool cannot name different stores. */
|
|
13
|
+
let derivedStoreName = (~identityProviderId: string): string =>
|
|
14
|
+
`ReventlessIdentityStore-${identityProviderId}`
|
|
15
|
+
|
|
16
|
+
/** The Cognito `sub`. The trigger reads by it on every token it mints. */
|
|
17
|
+
let partitionKey = "id"
|
|
18
|
+
|
|
19
|
+
/** The domain id the row maps that subject to. */
|
|
20
|
+
let userIdAttribute = "userId"
|
|
21
|
+
|
|
22
|
+
/** Keyed on the subject alone: one identity, one domain id, on every platform
|
|
23
|
+
the provider serves. The reverse direction, domain id to subject, is an
|
|
24
|
+
index to add when the first caller for it exists; a global index can be
|
|
25
|
+
added to a live table. */
|
|
26
|
+
let expectedKeySchema: array<(string, string)> = [(partitionKey, "HASH")]
|
|
27
|
+
|
|
28
|
+
/** Why an existing table under this name cannot serve, if it cannot. A table
|
|
29
|
+
with the wrong key is found by the deploy and missed by every read, so the
|
|
30
|
+
claim is never added and every new account silently keeps its `sub`. */
|
|
31
|
+
let keySchemaRefusal = (~tableName: string, ~actual: array<(string, string)>): option<string> => {
|
|
32
|
+
let actualKey = Auth_ActiveRoleStore_Schema.describeKeySchema(actual)
|
|
33
|
+
let wantedKey = Auth_ActiveRoleStore_Schema.describeKeySchema(expectedKeySchema)
|
|
34
|
+
actualKey == wantedKey
|
|
35
|
+
? None
|
|
36
|
+
: Some(
|
|
37
|
+
`table "${tableName}" already exists with key schema [${actualKey}], but the identity store needs [${wantedKey}]. A row written under one key is invisible to a read under the other, so every new account would silently keep its provider id. Rename or remove the table; its rows are a mapping the provisioning run rewrites.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Auth_ActiveRoleStore_Schema$ReventlessAws from "./Auth_ActiveRoleStore_Schema.res.mjs";
|
|
4
|
+
|
|
5
|
+
function derivedStoreName(identityProviderId) {
|
|
6
|
+
return `ReventlessIdentityStore-` + identityProviderId;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let partitionKey = "id";
|
|
10
|
+
|
|
11
|
+
let expectedKeySchema = [[
|
|
12
|
+
partitionKey,
|
|
13
|
+
"HASH"
|
|
14
|
+
]];
|
|
15
|
+
|
|
16
|
+
function keySchemaRefusal(tableName, actual) {
|
|
17
|
+
let actualKey = Auth_ActiveRoleStore_Schema$ReventlessAws.describeKeySchema(actual);
|
|
18
|
+
let wantedKey = Auth_ActiveRoleStore_Schema$ReventlessAws.describeKeySchema(expectedKeySchema);
|
|
19
|
+
if (actualKey === wantedKey) {
|
|
20
|
+
return;
|
|
21
|
+
} else {
|
|
22
|
+
return `table "` + tableName + `" already exists with key schema [` + actualKey + `], but the identity store needs [` + wantedKey + `]. A row written under one key is invisible to a read under the other, so every new account would silently keep its provider id. Rename or remove the table; its rows are a mapping the provisioning run rewrites.`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let userIdAttribute = "userId";
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
derivedStoreName,
|
|
30
|
+
partitionKey,
|
|
31
|
+
userIdAttribute,
|
|
32
|
+
expectedKeySchema,
|
|
33
|
+
keySchemaRefusal,
|
|
34
|
+
}
|
|
35
|
+
/* No side effect */
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// The identity provider a deployed platform provisions: principals in its
|
|
2
|
+
// Cognito pool, and each new one's domain id in the identity store the pool's
|
|
3
|
+
// pre-token trigger reads. The AWS counterpart of the local platform's
|
|
4
|
+
// `Capability_IdentityProvider_UserStore`, and reached the same way — on the
|
|
5
|
+
// capability record a slice's `translate` receives.
|
|
6
|
+
//
|
|
7
|
+
// Pulumi-free so it ships in the slice Lambda. The pool calls go through an
|
|
8
|
+
// injected `ops` record so the sequencing below — what is undone when a later
|
|
9
|
+
// step fails, which failures are verdicts — is testable without a pool.
|
|
10
|
+
//
|
|
11
|
+
// The principal's handle is the username, and the username is the address the
|
|
12
|
+
// person is reached at, as on the local platform.
|
|
13
|
+
|
|
14
|
+
open Reventless
|
|
15
|
+
|
|
16
|
+
/** The calls a provider makes, one per pool or store operation. Each throws the
|
|
17
|
+
SDK's error on failure; [failureOf] classifies it. */
|
|
18
|
+
type ops = {
|
|
19
|
+
/** Creates the account and answers its `sub`. */
|
|
20
|
+
createUser: (~username: string, ~attributes: array<(string, string)>) => promise<option<string>>,
|
|
21
|
+
subOf: (~username: string) => promise<option<string>>,
|
|
22
|
+
setPassword: (~username: string, ~password: string) => promise<unit>,
|
|
23
|
+
addToGroup: (~username: string, ~group: string) => promise<unit>,
|
|
24
|
+
removeFromGroup: (~username: string, ~group: string) => promise<unit>,
|
|
25
|
+
deleteUser: (~username: string) => promise<unit>,
|
|
26
|
+
putUserId: (~sub: string, ~userId: string) => promise<unit>,
|
|
27
|
+
deleteUserId: (~sub: string) => promise<unit>,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@get @return(nullable) external errorName: JsExn.t => option<string> = "name"
|
|
31
|
+
|
|
32
|
+
let codeOf = (exn: exn): option<string> => exn->JsExn.fromException->Option.flatMap(errorName)
|
|
33
|
+
|
|
34
|
+
let messageOf = (exn: exn): string =>
|
|
35
|
+
exn
|
|
36
|
+
->JsExn.fromException
|
|
37
|
+
->Option.flatMap(JsExn.message)
|
|
38
|
+
->Option.getOr("the identity provider failed")
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
A pool failure as the capability's answer.
|
|
42
|
+
|
|
43
|
+
`Refused` only where the pool has judged the request itself — a password its
|
|
44
|
+
policy rejects, an attribute it will not take, a group that does not exist.
|
|
45
|
+
Everything else is `Unavailable`, retried: throttling, a network blip, a role
|
|
46
|
+
missing its grant. A caller reaching `createPrincipal` has already recorded that
|
|
47
|
+
the address was proven, and treating an outage as a verdict would strand them.
|
|
48
|
+
*/
|
|
49
|
+
let failureOf = (exn: exn): IdentityProvider.failure =>
|
|
50
|
+
switch codeOf(exn) {
|
|
51
|
+
| Some("UsernameExistsException" | "AliasExistsException") => Conflict
|
|
52
|
+
| Some(
|
|
53
|
+
"InvalidPasswordException"
|
|
54
|
+
| "InvalidParameterException"
|
|
55
|
+
| "UserNotFoundException"
|
|
56
|
+
| "ResourceNotFoundException",
|
|
57
|
+
) =>
|
|
58
|
+
Refused(messageOf(exn))
|
|
59
|
+
| _ => Unavailable(messageOf(exn))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let usernameOf = (contact: Messaging.recipient): result<string, IdentityProvider.failure> =>
|
|
63
|
+
switch contact {
|
|
64
|
+
| ToEmail(email) => Ok(Email.toString(email))
|
|
65
|
+
| ToSms(phone) => Ok(Phone.toString(phone))
|
|
66
|
+
| ToPush(_) => Error(Refused("a push address cannot be signed in with"))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Verified, because a principal is only created after the address was proven —
|
|
70
|
+
the ordering the registration chapter is built on. */
|
|
71
|
+
let attributesOf = (contact: Messaging.recipient): array<(string, string)> =>
|
|
72
|
+
switch contact {
|
|
73
|
+
| ToEmail(email) => [("email", Email.toString(email)), ("email_verified", "true")]
|
|
74
|
+
| ToSms(phone) => [("phone_number", Phone.toString(phone)), ("phone_number_verified", "true")]
|
|
75
|
+
| ToPush(_) => []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let attempt = async (run: unit => promise<'a>): result<'a, IdentityProvider.failure> =>
|
|
79
|
+
try Ok(await run()) catch {
|
|
80
|
+
| exn => Error(failureOf(exn))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let withOps = (ops: ops, ~hasStore: bool): IdentityProvider.t => {
|
|
84
|
+
// Everything after the account exists, in order. A failure here undoes the
|
|
85
|
+
// account before answering, so a retry starts from nothing: left in place, the
|
|
86
|
+
// retry would meet `UsernameExistsException` and report a `Conflict` for a
|
|
87
|
+
// person whose first attempt merely timed out.
|
|
88
|
+
let complete = async (~username, ~sub, ~credential: IdentityProvider.credential, ~groups) => {
|
|
89
|
+
let steps = async () => {
|
|
90
|
+
switch credential {
|
|
91
|
+
| Password(password) => await ops.setPassword(~username, ~password)
|
|
92
|
+
| NoCredential => ()
|
|
93
|
+
}
|
|
94
|
+
for i in 0 to groups->Array.length - 1 {
|
|
95
|
+
await ops.addToGroup(~username, ~group=groups->Array.getUnsafe(i))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Minted here and only here: an account the pool made before the store
|
|
99
|
+
// existed keeps its `sub`, and nothing re-keys it.
|
|
100
|
+
if hasStore {
|
|
101
|
+
await ops.putUserId(~sub, ~userId=ReventlessCore.Auth_UserId.mint())
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
switch await attempt(steps) {
|
|
105
|
+
| Ok() => Ok({IdentityProvider.providerId: username})
|
|
106
|
+
| Error(_) as failed =>
|
|
107
|
+
let _ = await attempt(() => ops.deleteUser(~username))
|
|
108
|
+
failed
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let createPrincipal = async (~contact, ~credential, ~groups) =>
|
|
113
|
+
switch usernameOf(contact) {
|
|
114
|
+
| Error(_) as e => e
|
|
115
|
+
| Ok(username) =>
|
|
116
|
+
switch await attempt(() => ops.createUser(~username, ~attributes=attributesOf(contact))) {
|
|
117
|
+
| Error(_) as e => e
|
|
118
|
+
| Ok(created) =>
|
|
119
|
+
let sub = switch created {
|
|
120
|
+
| Some(_) => created
|
|
121
|
+
| None => (await attempt(() => ops.subOf(~username)))->Result.getOr(None)
|
|
122
|
+
}
|
|
123
|
+
switch sub {
|
|
124
|
+
| None =>
|
|
125
|
+
let _ = await attempt(() => ops.deleteUser(~username))
|
|
126
|
+
Error(IdentityProvider.Unavailable(`the pool created ${username} but named no sub`))
|
|
127
|
+
| Some(sub) => await complete(~username, ~sub, ~credential, ~groups)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let addToGroup = (~principal: IdentityProvider.principal, ~group) =>
|
|
133
|
+
attempt(() => ops.addToGroup(~username=principal.providerId, ~group))
|
|
134
|
+
|
|
135
|
+
let removeFromGroup = (~principal: IdentityProvider.principal, ~group) =>
|
|
136
|
+
attempt(() => ops.removeFromGroup(~username=principal.providerId, ~group))
|
|
137
|
+
|
|
138
|
+
// Deleting what is gone is the retry of a delete that happened, so a missing
|
|
139
|
+
// account answers `Ok`. The mapping row goes last, best effort: a row whose
|
|
140
|
+
// principal is gone is never read on the request path, since no token is
|
|
141
|
+
// minted for it.
|
|
142
|
+
let deletePrincipal = async (~principal: IdentityProvider.principal) => {
|
|
143
|
+
let username = principal.providerId
|
|
144
|
+
switch await attempt(() => ops.subOf(~username)) {
|
|
145
|
+
| Error(Refused(_)) => Ok()
|
|
146
|
+
| Error(_) as e => e
|
|
147
|
+
| Ok(sub) =>
|
|
148
|
+
switch await attempt(() => ops.deleteUser(~username)) {
|
|
149
|
+
| Error(Unavailable(_)) as e => e
|
|
150
|
+
| Error(_) | Ok() =>
|
|
151
|
+
switch (sub, hasStore) {
|
|
152
|
+
| (Some(sub), true) =>
|
|
153
|
+
let _ = await attempt(() => ops.deleteUserId(~sub))
|
|
154
|
+
| _ => ()
|
|
155
|
+
}
|
|
156
|
+
Ok()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
IdentityProvider.make(
|
|
162
|
+
~createPrincipal,
|
|
163
|
+
~addToGroup,
|
|
164
|
+
~removeFromGroup,
|
|
165
|
+
~deletePrincipal,
|
|
166
|
+
~operations=[CreatePrincipal, AddToGroup, RemoveFromGroup, DeletePrincipal],
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── The SDK ──────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
module Cognito = AwsSdk.CognitoIdentityServiceProvider
|
|
173
|
+
|
|
174
|
+
let subFrom = (attributes: option<array<Cognito.AdminCreateUserCommand.attributeType>>) =>
|
|
175
|
+
attributes->Option.getOr([])->Array.find(a => a.name == "sub")->Option.map(a => a.value)
|
|
176
|
+
|
|
177
|
+
let sdkOps = (~poolId: string, ~identityStore: string): ops => {
|
|
178
|
+
createUser: async (~username, ~attributes) => {
|
|
179
|
+
let out = await Cognito.AdminCreateUserCommand.make({
|
|
180
|
+
userPoolId: poolId,
|
|
181
|
+
username,
|
|
182
|
+
userAttributes: attributes->Array.map(((name, value)) => {
|
|
183
|
+
Cognito.AdminCreateUserCommand.name,
|
|
184
|
+
value,
|
|
185
|
+
}),
|
|
186
|
+
// No invitation: the person has proven the address already, and the
|
|
187
|
+
// invitation would carry a temporary password nobody should use.
|
|
188
|
+
messageAction: "SUPPRESS",
|
|
189
|
+
})->Cognito.AdminCreateUserCommand.send
|
|
190
|
+
out.user->Option.flatMap(u => subFrom(u.attributes))
|
|
191
|
+
},
|
|
192
|
+
subOf: async (~username) => {
|
|
193
|
+
let out = await Cognito.AdminGetUserCommand.make({
|
|
194
|
+
userPoolId: poolId,
|
|
195
|
+
username,
|
|
196
|
+
})->Cognito.AdminGetUserCommand.send
|
|
197
|
+
out.userAttributes->Option.getOr([])->Array.find(a => a.name == "sub")->Option.map(a => a.value)
|
|
198
|
+
},
|
|
199
|
+
setPassword: (~username, ~password) =>
|
|
200
|
+
Cognito.AdminSetUserPasswordCommand.make({
|
|
201
|
+
userPoolId: poolId,
|
|
202
|
+
username,
|
|
203
|
+
password,
|
|
204
|
+
permanent: true,
|
|
205
|
+
})->Cognito.AdminSetUserPasswordCommand.send,
|
|
206
|
+
addToGroup: (~username, ~group) =>
|
|
207
|
+
Cognito.AdminAddUserToGroupCommand.make({
|
|
208
|
+
userPoolId: poolId,
|
|
209
|
+
username,
|
|
210
|
+
groupName: group,
|
|
211
|
+
})->Cognito.AdminAddUserToGroupCommand.send,
|
|
212
|
+
removeFromGroup: (~username, ~group) =>
|
|
213
|
+
Cognito.AdminRemoveUserFromGroupCommand.make({
|
|
214
|
+
userPoolId: poolId,
|
|
215
|
+
username,
|
|
216
|
+
groupName: group,
|
|
217
|
+
})->Cognito.AdminRemoveUserFromGroupCommand.send,
|
|
218
|
+
deleteUser: (~username) =>
|
|
219
|
+
Cognito.AdminDeleteUserCommand.make({
|
|
220
|
+
userPoolId: poolId,
|
|
221
|
+
username,
|
|
222
|
+
})->Cognito.AdminDeleteUserCommand.send,
|
|
223
|
+
putUserId: async (~sub, ~userId) => {
|
|
224
|
+
let _ = await AwsSdk.DynamoDb_DocumentClient.PutCommand.make({
|
|
225
|
+
tableName: identityStore,
|
|
226
|
+
item: JSON.Encode.object(
|
|
227
|
+
Dict.fromArray([
|
|
228
|
+
(Auth_IdentityStore_Schema.partitionKey, JSON.Encode.string(sub)),
|
|
229
|
+
(Auth_IdentityStore_Schema.userIdAttribute, JSON.Encode.string(userId)),
|
|
230
|
+
]),
|
|
231
|
+
),
|
|
232
|
+
})->AwsSdk.DynamoDb_DocumentClient.PutCommand.send
|
|
233
|
+
},
|
|
234
|
+
deleteUserId: async (~sub) => {
|
|
235
|
+
let _ = await AwsSdk.DynamoDb_DocumentClient.DeleteCommand.make({
|
|
236
|
+
tableName: identityStore,
|
|
237
|
+
key: Dict.fromArray([(Auth_IdentityStore_Schema.partitionKey, JSON.Encode.string(sub))]),
|
|
238
|
+
})->AwsSdk.DynamoDb_DocumentClient.DeleteCommand.send
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
The provider a slice Lambda is handed, from the two values the deploy put in its
|
|
244
|
+
environment. No pool is the deployment gap it always was, answered `Unavailable`
|
|
245
|
+
with no operations. No store means the pool predates it: principals are still
|
|
246
|
+
made, and each keeps its `sub` as its id, as every account before the store did.
|
|
247
|
+
*/
|
|
248
|
+
let provider = (~poolId: string, ~identityStore: string): IdentityProvider.t =>
|
|
249
|
+
poolId == ""
|
|
250
|
+
? IdentityProvider.unavailable(~reason="no identity provider is configured for this platform")
|
|
251
|
+
: withOps(sdkOps(~poolId, ~identityStore), ~hasStore=identityStore != "")
|