Skip to content
Merged
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
4 changes: 3 additions & 1 deletion cloudformation/scenarios/minute/cdk/bin/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { MinuteStack } from '../lib/minute-stack';
const app = new cdk.App();

new MinuteStack(app, 'MinuteStack', {
// No env — environment-agnostic template for StackSet deployment
// No env, environment-agnostic template for StackSet deployment.
// ISB pool accounts have no CDK bootstrap; disable the Rule that checks for it.
description: 'Minute AI - Meeting Transcription & Minuting on AWS',
synthesizer: new cdk.DefaultStackSynthesizer({ generateBootstrapVersionRule: false }),
});
111 changes: 79 additions & 32 deletions cloudformation/scenarios/minute/cdk/lib/constructs/cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ export interface CdnConstructProps {
export class CdnConstruct extends Construct {
public readonly distribution: cloudfront.Distribution;
public readonly domainName: string;
public readonly basicAuthPassword: string;
public readonly authToken: string;

constructor(scope: Construct, id: string, props: CdnConstructProps) {
super(scope, id);

// Lambda custom resource to generate a random password and base64 credentials at deploy time
const passwordGenRole = new iam.Role(this, 'PasswordGenRole', {
// Custom resource that mints a single random token at deploy time. The token
// is delivered to the user via the MinuteLoginUrl stack output and validated
// by the CloudFront Function below; on first hit it becomes a HttpOnly cookie.
const authTokenRole = new iam.Role(this, 'AuthTokenRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
],
});

const passwordGenFn = new lambda.Function(this, 'PasswordGenFn', {
const authTokenFn = new lambda.Function(this, 'AuthTokenFn', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
role: passwordGenRole,
role: authTokenRole,
timeout: cdk.Duration.seconds(30),
code: lambda.Code.fromInline(`
const crypto = require('crypto');
Expand All @@ -38,46 +40,92 @@ exports.handler = async (e) => {
const rp = { Status: 'SUCCESS', PhysicalResourceId: e.LogicalResourceId, StackId: e.StackId, RequestId: e.RequestId, LogicalResourceId: e.LogicalResourceId, Data: {} };
if (e.RequestType === 'Delete') { await send(e.ResponseURL, rp); return; }
try {
const password = crypto.randomBytes(16).toString('hex');
const b64 = Buffer.from('admin:' + password).toString('base64');
rp.Data = { Password: password, Base64Credentials: b64 };
const token = crypto.randomBytes(16).toString('hex');
rp.Data = { Token: token };
await send(e.ResponseURL, rp);
} catch (err) { rp.Status = 'FAILED'; rp.Reason = err.message; await send(e.ResponseURL, rp); }
};
function send(u, d) { return new Promise((ok, fail) => { const b = JSON.stringify(d); const o = new URL(u); const opts = { hostname: o.hostname, port: 443, path: o.pathname + o.search, method: 'PUT', headers: { 'Content-Type': '', 'Content-Length': b.length } }; const req = https.request(opts, ok); req.on('error', fail); req.write(b); req.end(); }); }
`),
});

const passwordCR = new cdk.CustomResource(this, 'PasswordCR', {
serviceToken: passwordGenFn.functionArn,
const authTokenCR = new cdk.CustomResource(this, 'AuthTokenCR', {
serviceToken: authTokenFn.functionArn,
});
passwordCR.node.addDependency(passwordGenRole);
authTokenCR.node.addDependency(authTokenRole);

const base64Creds = passwordCR.getAttString('Base64Credentials');
this.basicAuthPassword = passwordCR.getAttString('Password');
this.authToken = authTokenCR.getAttString('Token');

// CloudFront Function for basic HTTP auth with deploy-time generated credentials.
// Uses L1 CfnFunction to inject the base64 credentials via Fn::Sub.
const cfnFunction = new cloudfront.CfnFunction(this, 'BasicAuthFunction', {
name: `NdxMinute-BasicAuth-${cdk.Aws.ACCOUNT_ID}`,
autoPublish: true,
functionConfig: {
comment: 'Basic HTTP auth for Minute AI',
runtime: 'cloudfront-js-2.0',
},
functionCode: cdk.Fn.sub(`function handler(event) {
// Deep-link to the stack's Outputs tab in the CloudFormation console.
// Rendered into the 401 page so a user who lands at the bare domain has a
// one-click path back to the MinuteLoginUrl value.
const stackOutputsUrl =
`https://${cdk.Aws.REGION}.console.aws.amazon.com/cloudformation/home` +
`?region=${cdk.Aws.REGION}#/stacks/outputs` +
`?filteringText=&filteringStatus=active&viewNested=true&stackId=${cdk.Aws.STACK_NAME}`;

// CloudFront Function (viewer-request) implementing magic-link + cookie auth.
// - First hit with ?key=<TOKEN>: 302 to clean URL with Set-Cookie.
// - Subsequent requests: cookie validated, pass-through.
// - Anything else: 401 with a helpful HTML page pointing at the Outputs tab.
//
// Note: if CloudFront access logging is enabled later, the ?key=<TOKEN>
// value will be captured in logs. Redeploying rotates the token.
const functionSrc = `function handler(event) {
var request = event.request;
var headers = request.headers;
var expected = 'Basic \${Creds}';
if (!headers.authorization || headers.authorization.value !== expected) {
var TOKEN = '\${Token}';
var COOKIE_NAME = 'ndx-minute-auth';

if (request.querystring.key && request.querystring.key.value === TOKEN) {
var qs = '';
for (var k in request.querystring) {
if (k === 'key') continue;
var v = request.querystring[k];
qs += (qs ? '&' : '') + k + (v.value ? '=' + v.value : '');
}
var location = request.uri + (qs ? '?' + qs : '');
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: { 'www-authenticate': { value: 'Basic realm="Minute AI"' } },
statusCode: 302,
statusDescription: 'Found',
headers: { location: { value: location } },
cookies: {
'ndx-minute-auth': {
value: TOKEN,
attributes: 'Max-Age=604800; Secure; HttpOnly; SameSite=Lax; Path=/'
}
}
};
}
return request;
}`, { Creds: base64Creds }),

if (request.cookies[COOKIE_NAME] && request.cookies[COOKIE_NAME].value === TOKEN) {
return request;
}

return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: { 'content-type': { value: 'text/html; charset=utf-8' } },
body: '<!doctype html><meta charset=utf-8><title>Access required</title>'
+ '<style>body{font-family:system-ui,Arial,sans-serif;max-width:640px;margin:4rem auto;padding:0 1rem;color:#0b0c0c}h1{font-size:1.5rem}code{background:#f3f2f1;padding:2px 6px;border-radius:3px}a{color:#1d70b8}</style>'
+ '<h1>NDX:Try Minute &mdash; session link required</h1>'
+ '<p>To open this app, use the <code>MinuteLoginUrl</code> value from the <strong>Outputs</strong> tab of your CloudFormation stack.</p>'
+ '<p><a href="\${StackOutputsUrl}">Open the CloudFormation Outputs tab &rarr;</a></p>'
+ '<p>In the AWS Console: <em>CloudFormation &rsaquo; Stacks &rsaquo; <code>\${StackName}</code> &rsaquo; Outputs &rsaquo; <code>MinuteLoginUrl</code></em>. Open that URL in your browser to start a session.</p>'
};
}`;

const cfnFunction = new cloudfront.CfnFunction(this, 'AuthFunction', {
name: `NdxMinute-Auth-${cdk.Aws.ACCOUNT_ID}`,
autoPublish: true,
functionConfig: {
comment: 'Magic-link + cookie auth for Minute AI',
runtime: 'cloudfront-js-2.0',
},
functionCode: cdk.Fn.sub(functionSrc, {
Token: this.authToken,
StackName: cdk.Aws.STACK_NAME,
StackOutputsUrl: stackOutputsUrl,
}),
});

this.distribution = new cloudfront.Distribution(this, 'Distribution', {
Expand All @@ -93,7 +141,6 @@ function send(u, d) { return new Promise((ok, fail) => { const b = JSON.stringif
},
});

// Associate the function via L1 override (since we used CfnFunction, not the L2 construct)
const cfnDistribution = this.distribution.node.defaultChild as cloudfront.CfnDistribution;
cfnDistribution.addPropertyOverride(
'DistributionConfig.DefaultCacheBehavior.FunctionAssociations',
Expand Down
25 changes: 10 additions & 15 deletions cloudformation/scenarios/minute/cdk/lib/minute-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,20 @@ export class MinuteStack extends cdk.Stack {
// Outputs
// ==========================================================================
new cdk.CfnOutput(this, 'MinuteUrl', {
description: 'URL to access Minute AI (HTTPS)',
description: 'URL to access Minute AI (HTTPS). Requires an active session; use MinuteLoginUrl first.',
value: `https://${cdn.domainName}`,
exportName: `${this.stackName}-MinuteUrl`,
});

// Convenience: same URL with credentials embedded so you can paste it
// straight into a browser address bar instead of copy-pasting the
// username and password from the BasicAuth* outputs.
// Single shareable URL. The ?key=<token> is consumed by the CloudFront
// Function on first hit, which sets a 7-day HttpOnly cookie and redirects
// to the clean URL. Works with fetch() and without browser auth dialogs.
new cdk.CfnOutput(this, 'MinuteLoginUrl', {
description: 'Minute AI URL with basic auth credentials embedded (paste into browser)',
value: `https://admin:${cdn.basicAuthPassword}@${cdn.domainName}`,
description: 'Paste this URL into your browser to start a Minute AI session (7-day cookie)',
value: `https://${cdn.domainName}/?key=${cdn.authToken}`,
});

new cdk.CfnOutput(this, 'CloudWatchLogsUrl', {
new cdk.CfnOutput(this, 'CloudWatchLogsUrl', {
description: 'CloudWatch Logs URL',
value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#logsV2:log-groups/log-group/${encodeURIComponent('/ndx-minute')}`,
});
Expand All @@ -99,14 +99,9 @@ new cdk.CfnOutput(this, 'CloudWatchLogsUrl', {
value: storage.dataBucket.bucketName,
});

new cdk.CfnOutput(this, 'BasicAuthUsername', {
description: 'Basic auth username for Minute AI',
value: 'admin',
});

new cdk.CfnOutput(this, 'BasicAuthPassword', {
description: 'Basic auth password for Minute AI (generated at deploy time)',
value: cdn.basicAuthPassword,
new cdk.CfnOutput(this, 'MinuteAuthToken', {
description: 'Raw auth token for Minute AI (the ?key= value in MinuteLoginUrl). Redeploy the stack to rotate.',
value: cdn.authToken,
});
}

Expand Down
Loading
Loading