Skip to content

Commit 4a0dcc2

Browse files
authored
Merge pull request #225 from co-cddo/fix/minute-cookie-auth
minute: replace basic auth with magic-link + cookie
2 parents e748ef5 + 6387441 commit 4a0dcc2

9 files changed

Lines changed: 5314 additions & 107 deletions

File tree

cloudformation/scenarios/minute/cdk/bin/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { MinuteStack } from '../lib/minute-stack';
66
const app = new cdk.App();
77

88
new MinuteStack(app, 'MinuteStack', {
9-
// No env — environment-agnostic template for StackSet deployment
9+
// No env, environment-agnostic template for StackSet deployment.
10+
// ISB pool accounts have no CDK bootstrap; disable the Rule that checks for it.
1011
description: 'Minute AI - Meeting Transcription & Minuting on AWS',
12+
synthesizer: new cdk.DefaultStackSynthesizer({ generateBootstrapVersionRule: false }),
1113
});

cloudformation/scenarios/minute/cdk/lib/constructs/cdn.ts

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,25 @@ export interface CdnConstructProps {
1313
export class CdnConstruct extends Construct {
1414
public readonly distribution: cloudfront.Distribution;
1515
public readonly domainName: string;
16-
public readonly basicAuthPassword: string;
16+
public readonly authToken: string;
1717

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

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

29-
const passwordGenFn = new lambda.Function(this, 'PasswordGenFn', {
31+
const authTokenFn = new lambda.Function(this, 'AuthTokenFn', {
3032
runtime: lambda.Runtime.NODEJS_20_X,
3133
handler: 'index.handler',
32-
role: passwordGenRole,
34+
role: authTokenRole,
3335
timeout: cdk.Duration.seconds(30),
3436
code: lambda.Code.fromInline(`
3537
const crypto = require('crypto');
@@ -38,46 +40,92 @@ exports.handler = async (e) => {
3840
const rp = { Status: 'SUCCESS', PhysicalResourceId: e.LogicalResourceId, StackId: e.StackId, RequestId: e.RequestId, LogicalResourceId: e.LogicalResourceId, Data: {} };
3941
if (e.RequestType === 'Delete') { await send(e.ResponseURL, rp); return; }
4042
try {
41-
const password = crypto.randomBytes(16).toString('hex');
42-
const b64 = Buffer.from('admin:' + password).toString('base64');
43-
rp.Data = { Password: password, Base64Credentials: b64 };
43+
const token = crypto.randomBytes(16).toString('hex');
44+
rp.Data = { Token: token };
4445
await send(e.ResponseURL, rp);
4546
} catch (err) { rp.Status = 'FAILED'; rp.Reason = err.message; await send(e.ResponseURL, rp); }
4647
};
4748
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(); }); }
4849
`),
4950
});
5051

51-
const passwordCR = new cdk.CustomResource(this, 'PasswordCR', {
52-
serviceToken: passwordGenFn.functionArn,
52+
const authTokenCR = new cdk.CustomResource(this, 'AuthTokenCR', {
53+
serviceToken: authTokenFn.functionArn,
5354
});
54-
passwordCR.node.addDependency(passwordGenRole);
55+
authTokenCR.node.addDependency(authTokenRole);
5556

56-
const base64Creds = passwordCR.getAttString('Base64Credentials');
57-
this.basicAuthPassword = passwordCR.getAttString('Password');
57+
this.authToken = authTokenCR.getAttString('Token');
5858

59-
// CloudFront Function for basic HTTP auth with deploy-time generated credentials.
60-
// Uses L1 CfnFunction to inject the base64 credentials via Fn::Sub.
61-
const cfnFunction = new cloudfront.CfnFunction(this, 'BasicAuthFunction', {
62-
name: `NdxMinute-BasicAuth-${cdk.Aws.ACCOUNT_ID}`,
63-
autoPublish: true,
64-
functionConfig: {
65-
comment: 'Basic HTTP auth for Minute AI',
66-
runtime: 'cloudfront-js-2.0',
67-
},
68-
functionCode: cdk.Fn.sub(`function handler(event) {
59+
// Deep-link to the stack's Outputs tab in the CloudFormation console.
60+
// Rendered into the 401 page so a user who lands at the bare domain has a
61+
// one-click path back to the MinuteLoginUrl value.
62+
const stackOutputsUrl =
63+
`https://${cdk.Aws.REGION}.console.aws.amazon.com/cloudformation/home` +
64+
`?region=${cdk.Aws.REGION}#/stacks/outputs` +
65+
`?filteringText=&filteringStatus=active&viewNested=true&stackId=${cdk.Aws.STACK_NAME}`;
66+
67+
// CloudFront Function (viewer-request) implementing magic-link + cookie auth.
68+
// - First hit with ?key=<TOKEN>: 302 to clean URL with Set-Cookie.
69+
// - Subsequent requests: cookie validated, pass-through.
70+
// - Anything else: 401 with a helpful HTML page pointing at the Outputs tab.
71+
//
72+
// Note: if CloudFront access logging is enabled later, the ?key=<TOKEN>
73+
// value will be captured in logs. Redeploying rotates the token.
74+
const functionSrc = `function handler(event) {
6975
var request = event.request;
70-
var headers = request.headers;
71-
var expected = 'Basic \${Creds}';
72-
if (!headers.authorization || headers.authorization.value !== expected) {
76+
var TOKEN = '\${Token}';
77+
var COOKIE_NAME = 'ndx-minute-auth';
78+
79+
if (request.querystring.key && request.querystring.key.value === TOKEN) {
80+
var qs = '';
81+
for (var k in request.querystring) {
82+
if (k === 'key') continue;
83+
var v = request.querystring[k];
84+
qs += (qs ? '&' : '') + k + (v.value ? '=' + v.value : '');
85+
}
86+
var location = request.uri + (qs ? '?' + qs : '');
7387
return {
74-
statusCode: 401,
75-
statusDescription: 'Unauthorized',
76-
headers: { 'www-authenticate': { value: 'Basic realm="Minute AI"' } },
88+
statusCode: 302,
89+
statusDescription: 'Found',
90+
headers: { location: { value: location } },
91+
cookies: {
92+
'ndx-minute-auth': {
93+
value: TOKEN,
94+
attributes: 'Max-Age=604800; Secure; HttpOnly; SameSite=Lax; Path=/'
95+
}
96+
}
7797
};
7898
}
79-
return request;
80-
}`, { Creds: base64Creds }),
99+
100+
if (request.cookies[COOKIE_NAME] && request.cookies[COOKIE_NAME].value === TOKEN) {
101+
return request;
102+
}
103+
104+
return {
105+
statusCode: 401,
106+
statusDescription: 'Unauthorized',
107+
headers: { 'content-type': { value: 'text/html; charset=utf-8' } },
108+
body: '<!doctype html><meta charset=utf-8><title>Access required</title>'
109+
+ '<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>'
110+
+ '<h1>NDX:Try Minute &mdash; session link required</h1>'
111+
+ '<p>To open this app, use the <code>MinuteLoginUrl</code> value from the <strong>Outputs</strong> tab of your CloudFormation stack.</p>'
112+
+ '<p><a href="\${StackOutputsUrl}">Open the CloudFormation Outputs tab &rarr;</a></p>'
113+
+ '<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>'
114+
};
115+
}`;
116+
117+
const cfnFunction = new cloudfront.CfnFunction(this, 'AuthFunction', {
118+
name: `NdxMinute-Auth-${cdk.Aws.ACCOUNT_ID}`,
119+
autoPublish: true,
120+
functionConfig: {
121+
comment: 'Magic-link + cookie auth for Minute AI',
122+
runtime: 'cloudfront-js-2.0',
123+
},
124+
functionCode: cdk.Fn.sub(functionSrc, {
125+
Token: this.authToken,
126+
StackName: cdk.Aws.STACK_NAME,
127+
StackOutputsUrl: stackOutputsUrl,
128+
}),
81129
});
82130

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

96-
// Associate the function via L1 override (since we used CfnFunction, not the L2 construct)
97144
const cfnDistribution = this.distribution.node.defaultChild as cloudfront.CfnDistribution;
98145
cfnDistribution.addPropertyOverride(
99146
'DistributionConfig.DefaultCacheBehavior.FunctionAssociations',

cloudformation/scenarios/minute/cdk/lib/minute-stack.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,20 @@ export class MinuteStack extends cdk.Stack {
7676
// Outputs
7777
// ==========================================================================
7878
new cdk.CfnOutput(this, 'MinuteUrl', {
79-
description: 'URL to access Minute AI (HTTPS)',
79+
description: 'URL to access Minute AI (HTTPS). Requires an active session; use MinuteLoginUrl first.',
8080
value: `https://${cdn.domainName}`,
8181
exportName: `${this.stackName}-MinuteUrl`,
8282
});
8383

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

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

102-
new cdk.CfnOutput(this, 'BasicAuthUsername', {
103-
description: 'Basic auth username for Minute AI',
104-
value: 'admin',
105-
});
106-
107-
new cdk.CfnOutput(this, 'BasicAuthPassword', {
108-
description: 'Basic auth password for Minute AI (generated at deploy time)',
109-
value: cdn.basicAuthPassword,
102+
new cdk.CfnOutput(this, 'MinuteAuthToken', {
103+
description: 'Raw auth token for Minute AI (the ?key= value in MinuteLoginUrl). Redeploy the stack to rotate.',
104+
value: cdn.authToken,
110105
});
111106
}
112107

0 commit comments

Comments
 (0)