Skip to content

Seed POC: passwordless and MFA #2454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/four-pumas-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@aws-amplify/seed': minor
'@aws-amplify/backend-cli': minor
---

added seed command and seed package
5,765 changes: 3,697 additions & 2,068 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from './sandbox_command.js';
import { SandboxSingletonFactory } from '@aws-amplify/sandbox';
import { SandboxDeleteCommand } from './sandbox-delete/sandbox_delete_command.js';
import { SandboxSeedCommand } from './sandbox_seed_command.js';
import { SandboxBackendIdResolver } from './sandbox_id_resolver.js';
import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js';
import { LocalNamespaceResolver } from '../../backend-identifier/local_namespace_resolver.js';
Expand Down Expand Up @@ -75,7 +76,11 @@ export const createSandboxCommand = (): CommandModule<
const commandMiddleWare = new CommandMiddleware(printer);
return new SandboxCommand(
sandboxFactory,
[new SandboxDeleteCommand(sandboxFactory), createSandboxSecretCommand()],
[
new SandboxDeleteCommand(sandboxFactory),
createSandboxSecretCommand(),
new SandboxSeedCommand(),
],
clientConfigGeneratorAdapter,
commandMiddleWare,
eventHandlerFactory.getSandboxEventHandlers
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/commands/sandbox/sandbox_seed_command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Argv, CommandModule } from 'yargs';
import path from 'path';
import { existsSync } from 'fs';
import { execa } from 'execa';
import { SandboxBackendIdResolver } from './sandbox_id_resolver.js';
import { PackageJsonReader } from '@aws-amplify/platform-core';
import { LocalNamespaceResolver } from '../../backend-identifier/local_namespace_resolver.js';

/**
*
*/
export class SandboxSeedCommand implements CommandModule<object> {
/**
* @inheritDoc
*/
readonly command: string;

/**
* @inheritDoc
*/
readonly describe: string;

/**
* Seeds sandbox environment.
*/
constructor() {
this.command = 'seed';
this.describe = 'Seeds sandbox environment';
}

/**
* @inheritDoc
*/
handler = async (): Promise<void> => {
const sandboxID = await new SandboxBackendIdResolver(
new LocalNamespaceResolver(new PackageJsonReader())
).resolve();

//most of this comes from the initial POC for seed, changed filepath to be more inline with discussions that have happened since then
const seedPath = path.join('seed.ts');
await execa('tsx', [seedPath], {
cwd: process.cwd(),
stdio: 'inherit',
env: {
AMPLIFY_SANDBOX_IDENTIFIER: JSON.stringify(sandboxID),
},
});
};

/**
* @inheritDoc
*/
//this section also comes from the initial POC for seed
builder = (yargs: Argv) => {
return yargs.check(() => {
//seed path may need to be more flexible or be in a different place
const seedPath = path.join(process.cwd(), 'seed.ts');
if (!existsSync(seedPath)) {
throw new Error(`${seedPath} must exist`);
}
return true;
});
};
}
14 changes: 14 additions & 0 deletions packages/seed/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Be very careful editing this file. It is crafted to work around [this issue](https://github.com/npm/npm/issues/4479)

# First ignore everything
**/*

# Then add back in transpiled js and ts declaration files
!lib/**/*.js
!lib/**/*.d.ts

# Then ignore test js and ts declaration files
*.test.js
*.test.d.ts

# This leaves us with including only js and ts declaration files of functional code
25 changes: 25 additions & 0 deletions packages/seed/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## API Report File for "@aws-amplify/seed"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

// @public (undocumented)
export type AuthClient = {
createUser: (newUser: AuthUser) => Promise<AuthUser>;
signInUser: (userToSignIn: AuthUser) => Promise<void>;
};

// @public (undocumented)
export type AuthUser = {
username: string;
signUpOption: 'MFA' | 'Passwordless';
password?: string;
};

// @public
export const getAuthClient: (outputs: any) => AuthClient;

// (No @packageDocumentation comment for this package)

```
3 changes: 3 additions & 0 deletions packages/seed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Description

Replace with a description of this package
3 changes: 3 additions & 0 deletions packages/seed/api-extractor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../api-extractor.base.json"
}
27 changes: 27 additions & 0 deletions packages/seed/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@aws-amplify/seed",
"version": "0.1.0",
"type": "module",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./lib/index.d.ts",
"import": "./lib/index.js",
"require": "./lib/index.js"
}
},
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"update:api": "api-extractor run --local"
},
"license": "Apache-2.0",
"dependencies": {
"@aws-amplify/cli-core": "^1.2.3",
"@aws-amplify/client-config": "^1.5.5",
"@aws-sdk/client-cognito-identity-provider": "^3.734.0",
"aws-amplify": "^6.12.1"
}
}
65 changes: 65 additions & 0 deletions packages/seed/src/async_lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Example usage:
* const myLock = new AsyncLock();
*
* async function asyncFunction() {
* await myLock.acquire();
* try {
* // Code that requires exclusive access to a shared resource
* console.log('Accessing shared resource...');
* await someAsyncOperation();
* } finally {
* myLock.release();
* }
* }
*
* asyncFunction();
*/
// took this from initial Seed POC, for the purposes of creating a new user
// TODO this is a copy for the POC purposes.
export class AsyncLock {
private isLocked: boolean;
private readonly queue: Array<(value?: never) => void>;

/**
* Creates async lock.
*/
constructor(private readonly defaultTimeoutMs?: number) {
this.isLocked = false;
this.queue = [];
}

acquire = async (timeoutMs?: number): Promise<void> => {
const lockPromise = new Promise<void>((resolve) => {
if (!this.isLocked) {
this.isLocked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
timeoutMs = timeoutMs ?? this.defaultTimeoutMs;
if (timeoutMs) {
const timeoutPromise = new Promise<void>((resolve, reject) =>
setTimeout(
() =>
reject(
new Error(`Unable to acquire async lock in ${timeoutMs}ms.`)
),
timeoutMs
)
);
return Promise.race<void>([lockPromise, timeoutPromise]);
}
return lockPromise;
};

release = (): void => {
const resolve = this.queue.shift();
if (resolve) {
resolve();
} else {
this.isLocked = false;
}
};
}
152 changes: 152 additions & 0 deletions packages/seed/src/auth_client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* eslint-disable */
import { AsyncLock } from './async_lock.js';
import { AuthClient, AuthUser } from './types.js';
import {
AdminCreateUserCommand,
AdminInitiateAuthCommand,
CognitoIdentityProviderClient,
RespondToAuthChallengeCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import { randomUUID, sign } from 'node:crypto';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import sign.
import { ClientConfigVersionTemplateType } from '@aws-amplify/client-config';
import * as auth from 'aws-amplify/auth';
//import assert from 'assert';
import { mfaSignUp } from './mfa_authentication.js';
import { passwordlessSignUp } from './passwordless_authentication.js';
import { AmplifyPrompter } from '@aws-amplify/cli-core';

// Took the skeleton of this from the initial Seed POC
export class DefaultAuthClient implements AuthClient {
/**
* Asynchronous lock is used to assure that all calls to Amplify JS library are
* made in single transaction. This is because that library maintains global state,
* for example auth session.
*/
// TODO setting timeout makes node process delay exit until that promise resolves, it should be handled somehow.
private readonly lock: AsyncLock = new AsyncLock();

private readonly userPoolId: string;
private readonly userPoolClientId: string;
private readonly identityPoolId: string;
private readonly allowGuestAccess: boolean | undefined;

/**
* Creates Amplify Auth client.
*/
constructor(
private readonly cognitoIdentityProviderClient: CognitoIdentityProviderClient,
authConfig: NonNullable<ClientConfigVersionTemplateType<'1'>['auth']>
) {
if (!authConfig.identity_pool_id) {
throw new Error('Client config must have identity pool id.');
}
this.userPoolId = authConfig.user_pool_id;
this.userPoolClientId = authConfig.user_pool_client_id;
this.identityPoolId = authConfig.identity_pool_id;
this.allowGuestAccess = authConfig.unauthenticated_identities_enabled;
}

// https://docs.amplify.aws/react/build-a-backend/auth/connect-your-frontend/sign-in/#sign-in-with-passwordless-methods
createUser = async (user: AuthUser): Promise<AuthUser> => {
await this.lock.acquire();
try {
console.log(`creating ${user.username}`);

// in case there's already signed user in the session.
await auth.signOut();

switch (user.signUpOption) {
case 'Passwordless':
await this.cognitoIdentityProviderClient.send(
new AdminCreateUserCommand({
Username: user.username,
UserPoolId: this.userPoolId,
MessageAction: 'SUPPRESS',
})
);

await passwordlessSignUp(user.username);
console.log('Sign in successful');
break;
case 'MFA':
const temporaryPassword = `Test1@Temp${randomUUID().toString()}`;
await this.cognitoIdentityProviderClient.send(
new AdminCreateUserCommand({
Username: user.username,
TemporaryPassword: temporaryPassword,
UserPoolId: this.userPoolId,
MessageAction: 'SUPPRESS',
})
);

await mfaSignUp(user.username, temporaryPassword, user.password!);
console.log('Sign in successful');
break;
}
return user;
} finally {
try {
// sign out to leave ok state;
await auth.signOut();
} catch (e) {
// eat it
}
console.log(`user ${user.username} created`);
this.lock.release();
}
};

//this works, but I would like for it to not require a challenge everytime someone wants to sign in with a user
signInUser = async (user: AuthUser) => {
switch (user.signUpOption) {
case 'Passwordless':
const signIn = await this.cognitoIdentityProviderClient.send(
new AdminInitiateAuthCommand({
AuthFlow: 'USER_AUTH',
ClientId: this.userPoolClientId,
UserPoolId: this.userPoolId,
AuthParameters: {
USERNAME: user.username,
},
})
);

const challengeResponse = await AmplifyPrompter.input({
message: `Input a challenge response for ${user.username}: `,
});
const output = await this.cognitoIdentityProviderClient.send(
new RespondToAuthChallengeCommand({
ChallengeName: 'EMAIL_OTP',
ChallengeResponses: {
USERNAME: user.username,
EMAIL_OTP_CODE: challengeResponse,
},
ClientId: this.userPoolClientId,
Session: signIn.Session,
})
);
console.log(output.AuthenticationResult);
console.log('Signed in');
break;
case 'MFA':
const signInResult = await auth.signIn({
username: user.username,
password: user.password,
});
console.log(signInResult.nextStep.signInStep);

if (
signInResult.nextStep.signInStep === 'CONFIRM_SIGN_IN_WITH_TOTP_CODE'
) {
const challengeResponse = await AmplifyPrompter.input({
message: `Input a challenge response for ${user.username}: `,
});
const totp = await auth.confirmSignIn({
challengeResponse: challengeResponse,
});
console.log(totp);
}
break;
}
};
}
Loading