@@ -13,23 +13,25 @@ export interface CdnConstructProps {
1313export 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 ( `
3537const 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};
4748function 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 — 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 →</a></p>'
113+ + '<p>In the AWS Console: <em>CloudFormation › Stacks › <code>\${StackName}</code> › Outputs › <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' ,
0 commit comments