Skip to content
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The CLI authentication uses the OAuth 2.0 device authentication flow, which is n
- [Node.js](https://nodejs.org/) (v16 or higher)
- [Serverless framework](https://www.serverless.com/framework) (v3 or higher)
- Your custom domain (easier if already registered with Route53)
- A Mailgun API Key, stored as an SSM Parameter Store securestring named `/weshare/<STAGE>/mailgunApiKey` (`STAGE` is `prd` by default)
- A bash-compatible environment (Tested on macOS but it should also work on Linux and Windows with subsystem for Linux)
- [`jq`](https://stedolan.github.io/jq/): optional but useful if you need to run some of the suggested CLI commands below

Expand Down Expand Up @@ -153,7 +154,7 @@ If you know a better way to streamline the first deployment, please create an is

### 4. Create users in the Cognito User pool

To be able to login into weshare, you need to create some users first. You can do this either from the AWS web console or programmatically.
To be able to log in with weshare, you need to create some users first. You can do this either from the AWS web console or programmatically.

Here's how to add a new user to the user pool from the AWS CLI:

Expand Down
8 changes: 4 additions & 4 deletions auth/code-verification-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
import cryptoRandomString from 'crypto-random-string'
import { COGNITO_OAUTH_CODE_URI, CODE_EXPIRY_SECONDS, CLIENT_ID, REDIRECT_URI, TABLE_NAME } from './config.js'
import { htmlResponse } from './util.js'
import { uiResponse } from './util.js'
import { DeviceAuthStatus } from './constants.js'

const tracer = new Tracer()
Expand Down Expand Up @@ -50,7 +50,7 @@ async function handler (event, context) {
logger.debug({ ddbQueryResponse })

if (ddbQueryResponse.Items?.length === 0) {
return htmlResponse(400, 'Invalid code. Maybe it expired?')
return uiResponse(undefined, 'Invalid code. Maybe it expired?')
}

const { pk, sk } = ddbQueryResponse.Items[0]
Expand Down Expand Up @@ -79,10 +79,10 @@ async function handler (event, context) {
logger.debug({ updateItemResponse })
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
return htmlResponse(400, 'The token is expired or already verified')
return uiResponse(undefined, 'The token is expired or already verified')
}
logger.error({ err })
return htmlResponse(500, `Oops. Something went wrong with request ID: ${context.awsRequestId}`)
return uiResponse(undefined, `Oops. Something went wrong with request ID: ${context.awsRequestId}`)
}

const destinationUrl = new URL(COGNITO_OAUTH_CODE_URI)
Expand Down
81 changes: 81 additions & 0 deletions auth/cognito-custom-message.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Mailgun from 'mailgun.js'
import formData from 'form-data'
import middy from '@middy/core'
import middySsm from '@middy/ssm'
import encryptionSdk from '@aws-crypto/client-node'
const { BASE_URL, STAGE, KEY_ALIAS, KEY_ARN } = process.env
const EMAIL_DOMAIN = 'sandbox4e5d8a28162548389a95edc71719a3a4.mailgun.org'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will change this to a proper from: domain when I set up Mailgun with this!


const { decrypt } = encryptionSdk.buildClient(encryptionSdk.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
const keyring = new encryptionSdk.KmsKeyringNode({ generatorKeyId: KEY_ARN, keyIds: [KEY_ALIAS] })

const globals = {}

/**
* Using Mailgun, send an invitation email when the event indicates an Invitation is being created
* using a Cognito password reset flow
*
* @param {*} event
*/
export async function cognitoCustomEmailSenderHandler (event) {
console.log(event)
const { request } = event
if (event.triggerSource === 'CustomEmailSender_ForgotPassword') {
const { email, email_verified: emailVerified } = request.userAttributes
if (emailVerified !== 'true') {
throw new Error('Email is not verified - unexpected ForgotPassword request: ' + JSON.stringify(request.userAttributes))
} else {
const encryptedCode = Buffer.from(request.code, 'base64')
const { plaintext: code } = await decrypt(keyring, encryptedCode)
const isInvitation = request?.clientMetadata?.InitationFlow === 'true'
const url = new URL(`${BASE_URL}/${isInvitation ? 'invitation' : 'confirm-reset'}`)
url.searchParams.set('email', email)
url.searchParams.set('code', code)

let htmlMessage
if (isInvitation) {
htmlMessage = `
<h1>You are invited 🤩</h1>

You have been invited to join Weshare.

<a href="${url}">Click here to accept the invitation</a>
`
} else {
htmlMessage = `
<h1>You forgot your password 🤪</h1>

<a href="${url}">Click here to reset your password</a>
`
}
await sendEmail(email, htmlMessage)
}
}
}

async function sendEmail (email, htmlMessage) {
const data = {
from: `Mailgun Sandbox <postmaster@${EMAIL_DOMAIN}>`,
to: [email],
subject: 'Hello',
html: htmlMessage
}
const response = await globals.mailgun.messages.create(EMAIL_DOMAIN, data)
console.log(response)
}

export const handler = middy()
.use(
middySsm({
fetchData: {
mailgunApiKey: `/weshare/${STAGE}/mailgunApiKey`
},
setToContext: true
})
)
.before((request) => {
const { mailgunApiKey } = request.context
const mailgun = new Mailgun(formData).client({ username: 'api', key: mailgunApiKey })
globals.mailgun = mailgun
})
.handler(cognitoCustomEmailSenderHandler)
8 changes: 4 additions & 4 deletions auth/config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export const TOKEN_REQUEST_INTERVAL_SECONDS = 2
export const CODE_EXPIRY_SECONDS = 5 * 60
export const { AWS_REGION, BASE_URL, TABLE_NAME, USER_POOL_DOMAIN, CLIENT_ID } = process.env
export const VERIFICATION_URI = `${BASE_URL}/auth/verify`
export const REDIRECT_URI = `${BASE_URL}/auth/callback`
export const COGNITO_OAUTH_BASE_URI = `https://${USER_POOL_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2`
export const { AWS_REGION, BASE_URL, API_BASE_URL, TABLE_NAME, USER_POOL_DOMAIN, CLIENT_ID } = process.env
export const VERIFICATION_URI = `${BASE_URL}/verify` // This is the front end page displayed for confirmation before beginning the format device code flow
export const REDIRECT_URI = `${API_BASE_URL}/auth/callback`
export const COGNITO_OAUTH_BASE_URI = `https://${USER_POOL_DOMAIN}/oauth2`
export const COGNITO_OAUTH_CODE_URI = `${COGNITO_OAUTH_BASE_URI}/authorize`
export const COGNITO_OAUTH_TOKEN_URI = `${COGNITO_OAUTH_BASE_URI}/token`
2 changes: 1 addition & 1 deletion auth/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ export const OAuthErrorCodes = {
UNAUTHORIZED_CLIENT: 'unauthorized_client',
UNSUPPORTED_GRANT_TYPE: 'unsupported_grant_type',
INVALID_SCOPE: 'invalid_scope'
}
}
24 changes: 24 additions & 0 deletions auth/events/sample-cognito-pw-reset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"version": "1",
"triggerSource": "CustomEmailSender_ForgotPassword",
"region": "eu-west-1",
"userPoolId": "eu-west-1_Jn1Hv0kUL",
"userName": "5c20be82-1b91-4e0c-837a-319545fa4940",
"callerContext": {
"awsSdkVersion": "aws-sdk-js-3.515.0",
"clientId": null
},
"request": {
"type": "customEmailSenderRequestV1",
"code": "AYA..................afEQc86+g=",
"clientMetadata": {
"InitationFlow": "true"
},
"userAttributes": {
"sub": "5c20be82-1b91-4e0c-837a-319545fa4940",
"cognito:user_status": "CONFIRMED",
"email_verified": "true",
"email": "eoin.shanaghy@gmail.com"
}
}
}
14 changes: 7 additions & 7 deletions auth/idp-callback-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import middy from '@middy/core'
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
import { CODE_EXPIRY_SECONDS, TABLE_NAME } from './config.js'
import { htmlResponse } from './util.js'
import { uiResponse } from './util.js'
import { DeviceAuthStatus } from './constants.js'

const tracer = new Tracer()
Expand All @@ -27,7 +27,7 @@ async function handler (event, context) {
if (!state || !code) {
// The user may have arrived here by logging in directly on the Cognito Hosted UI without initiating
// a proper Device Auth flow
return htmlResponse(400, 'To log in to weshare.click, use the <b>weshare CLI</b>')
return uiResponse(undefined, 'To log in to weshare.click, use the <b>weshare CLI</b>')
}

metrics.addMetric('IdpCallbackCount', MetricUnits.Count, 1)
Expand All @@ -48,7 +48,7 @@ async function handler (event, context) {
const ddbQueryResponse = await docClient.query(queryItemInput)
logger.debug({ ddbResponse: ddbQueryResponse }, 'Received query response')
if (ddbQueryResponse.Items?.length !== 1) {
return htmlResponse(400, 'Unable to verify user code')
return uiResponse(undefined, 'Unable to verify user code')
}

const { pk, sk } = ddbQueryResponse.Items[0]
Expand Down Expand Up @@ -94,16 +94,16 @@ async function handler (event, context) {
logger.debug({ ddbResponse }, 'Received update response')
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
return htmlResponse(400, 'The token is expired or already verified')
return uiResponse(undefined, 'The token is expired or already verified')
}
logger.error({ err })
return htmlResponse(500, `Oops. Something went wrong with request ID: ${context.awsRequestId}`)
return uiResponse(undefined, `Oops. Something went wrong with request ID: ${context.awsRequestId}`)
}

if (error) {
return htmlResponse(400, `Error(${error}): ${errorDescription}`)
return uiResponse(undefined, `${error}: ${errorDescription}`)
}
return htmlResponse(200, 'Thank you. You can return to the weshare client.')
return uiResponse('Thank you. You can return to the weshare client.')
}

export const handleEvent = middy(handler)
Expand Down
Loading