From e99a065f7701695f2d81ee97ba5aea006749b7c7 Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:41:13 +0900 Subject: [PATCH 1/6] fix: expose aws errors --- .github/workflows/docker-publish.yml | 2 +- src/services/storage.ts | 43 ++++++++++++++++------------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6b84c93..e0f10ad 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,7 +7,7 @@ name: Docker on: push: - branches: [ "main" ] + branches: [ "main", "dev" ] tags: [ "*" ] env: diff --git a/src/services/storage.ts b/src/services/storage.ts index 2550b61..deee444 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -23,28 +23,28 @@ export class StorageService { this.client = new S3(factory); } - private async fetchInstanceProfileCredentials(): Promise { + private async fetchInstanceProfileCredentials(): Promise<[Credentials | null, string | null]> { try { const metadataUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"; const roleName = await (await fetch(metadataUrl)).text(); const credentials = await (await fetch(`${metadataUrl}${roleName}`)).json(); - return { + return [{ awsAccessKeyId: credentials.AccessKeyId, awsSecretKey: credentials.SecretAccessKey, sessionToken: credentials.Token, - }; - } catch { - return null; + }, null]; + } catch (error) { + return [null, `Instance profile credentials failed: ${error instanceof Error ? error.message : String(error)}`]; } } - private async fetchIRSACredentials(): Promise { + private async fetchIRSACredentials(): Promise<[Credentials | null, string | null]> { const roleArn = Deno.env.get("AWS_ROLE_ARN"); const tokenFile = Deno.env.get("AWS_WEB_IDENTITY_TOKEN_FILE"); if (!roleArn || !tokenFile) { - return null; + return [null, "IRSA credentials not configured: missing AWS_ROLE_ARN or AWS_WEB_IDENTITY_TOKEN_FILE"]; } try { @@ -52,44 +52,49 @@ export class StorageService { const sts = new WebIdentityCredentials(roleArn, token); const creds = await sts.getCredentials(); - return { + return [{ awsAccessKeyId: creds.accessKeyId, awsSecretKey: creds.secretAccessKey, sessionToken: creds.sessionToken, - }; - } catch { - return null; + }, null]; + } catch (error) { + return [null, `IRSA credentials failed: ${error instanceof Error ? error.message : String(error)}`]; } } - private getStaticCredentials(): Credentials | null { + private getStaticCredentials(): [Credentials | null, string | null] { const accessKeyId = Deno.env.get("AWS_ACCESS_KEY_ID"); const secretKey = Deno.env.get("AWS_SECRET_ACCESS_KEY"); if (!accessKeyId || !secretKey) { - return null; + return [null, "Static credentials not configured: missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY"]; } - return { + return [{ awsAccessKeyId: accessKeyId, awsSecretKey: secretKey, - }; + }, null]; } private async resolveCredentials(): Promise { + const errors: string[] = []; + // Try IRSA first - const irsaCreds = await this.fetchIRSACredentials(); + const [irsaCreds, irsaError] = await this.fetchIRSACredentials(); if (irsaCreds) return irsaCreds; + if (irsaError) errors.push(irsaError); // Then try static credentials - const staticCreds = this.getStaticCredentials(); + const [staticCreds, staticError] = this.getStaticCredentials(); if (staticCreds) return staticCreds; + if (staticError) errors.push(staticError); // Finally try instance profile - const instanceCreds = await this.fetchInstanceProfileCredentials(); + const [instanceCreds, instanceError] = await this.fetchInstanceProfileCredentials(); if (instanceCreds) return instanceCreds; + if (instanceError) errors.push(instanceError); - throw new Error("No valid AWS credentials found"); + throw new Error(`No valid AWS credentials found:\n${errors.join('\n')}`); } async getSignedUrl(key: string, expiresIn = 3600): Promise { From aff7414f57126630ad7cd5a804ba5816e672bad2 Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 16:54:38 +0900 Subject: [PATCH 2/6] feature: update to deno 2 and initializeClient requirement --- Dockerfile | 2 +- src/services/storage.ts | 41 ++++++++++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a77e5aa..f6dd3e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use the official Deno image -FROM denoland/deno:1.46.3 +FROM denoland/deno:2.0.6 # Set working directory WORKDIR /app diff --git a/src/services/storage.ts b/src/services/storage.ts index deee444..3d033ab 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -2,6 +2,7 @@ import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/m import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts"; import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts"; import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts"; +import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.48/deno-dom-wasm.ts"; interface Credentials { awsAccessKeyId: string; @@ -11,10 +12,14 @@ interface Credentials { export class StorageService { private client: S3; - private credentials: Credentials; + private credentials!: Credentials; constructor(private bucket: string, private region: string) { - this.credentials = this.resolveCredentials(); + this.initializeClient(); + } + + private async initializeClient() { + this.credentials = await this.resolveCredentials(); const factory = new ApiFactory({ region: this.region, credentials: this.credentials, @@ -23,6 +28,13 @@ export class StorageService { this.client = new S3(factory); } + // Make sure any method that uses this.client waits for initialization + private async ensureInitialized() { + if (!this.client) { + await this.initializeClient(); + } + } + private async fetchInstanceProfileCredentials(): Promise<[Credentials | null, string | null]> { try { const metadataUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"; @@ -98,6 +110,7 @@ export class StorageService { } async getSignedUrl(key: string, expiresIn = 3600): Promise { + await this.ensureInitialized(); return await getSignedUrl({ accessKeyId: this.credentials.awsAccessKeyId, secretAccessKey: this.credentials.awsSecretKey, @@ -109,6 +122,7 @@ export class StorageService { } async listObjects(options: ListObjectsOptions): Promise { + await this.ensureInitialized(); try { const params: ListObjectsV2Request = { Bucket: this.bucket, @@ -160,10 +174,14 @@ export class StorageService { } class WebIdentityCredentials { + private parser: DOMParser; + constructor( private roleArn: string, private token: string, - ) {} + ) { + this.parser = new DOMParser(); + } async getCredentials(): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string }> { const params = new URLSearchParams({ @@ -183,15 +201,20 @@ class WebIdentityCredentials { } const xml = await response.text(); - const result = new DOMParser().parseFromString(xml, "text/xml"); + const result = this.parser.parseFromString(xml, "text/xml"); + if (!result) throw new Error("Failed to parse XML response"); const credentials = result.querySelector("Credentials"); if (!credentials) throw new Error("No credentials in response"); - return { - accessKeyId: credentials.querySelector("AccessKeyId")?.textContent ?? "", - secretAccessKey: credentials.querySelector("SecretAccessKey")?.textContent ?? "", - sessionToken: credentials.querySelector("SessionToken")?.textContent ?? "", - }; + const accessKeyId = credentials.querySelector("AccessKeyId")?.textContent; + const secretAccessKey = credentials.querySelector("SecretAccessKey")?.textContent; + const sessionToken = credentials.querySelector("SessionToken")?.textContent; + + if (!accessKeyId || !secretAccessKey || !sessionToken) { + throw new Error("Missing required credential fields in response"); + } + + return { accessKeyId, secretAccessKey, sessionToken }; } } From 7bc6533a6bb943e0bfc0235e1061ea20c44d18b9 Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:02:24 +0900 Subject: [PATCH 3/6] fix: migrate to jsr:@b-fuze/deno-dom --- deno.lock | 23 +++++++++++++---------- src/services/storage.ts | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/deno.lock b/deno.lock index 242abd5..ab3e684 100644 --- a/deno.lock +++ b/deno.lock @@ -1,14 +1,17 @@ { - "version": "3", - "packages": { - "specifiers": { - "npm:hono": "npm:hono@4.6.10" - }, - "npm": { - "hono@4.6.10": { - "integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==", - "dependencies": {} - } + "version": "4", + "specifiers": { + "jsr:@b-fuze/deno-dom@*": "0.1.48", + "npm:hono@*": "4.6.10" + }, + "jsr": { + "@b-fuze/deno-dom@0.1.48": { + "integrity": "bf5b591aef2e9e9c59adfcbb93a9ecd45bab5b7c8263625beafa5c8f1662e7da" + } + }, + "npm": { + "hono@4.6.10": { + "integrity": "sha512-IXXNfRAZEahFnWBhUUlqKEGF9upeE6hZoRZszvNkyAz/CYp+iVbxm3viMvStlagRJohjlBRGOQ7f4jfcV0XMGg==" } }, "redirects": { diff --git a/src/services/storage.ts b/src/services/storage.ts index 3d033ab..0a349fb 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -2,7 +2,7 @@ import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/m import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts"; import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts"; import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts"; -import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.48/deno-dom-wasm.ts"; +import { DOMParser, Element } from "jsr:@b-fuze/deno-dom"; interface Credentials { awsAccessKeyId: string; From 9809ef2c4d2ebdf11235463446c71e226fd7963e Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:11:56 +0900 Subject: [PATCH 4/6] fix: migrate to real xml parser --- deno.lock | 4 ++++ src/services/storage.ts | 30 ++++++++++++++---------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/deno.lock b/deno.lock index ab3e684..5a363e4 100644 --- a/deno.lock +++ b/deno.lock @@ -2,11 +2,15 @@ "version": "4", "specifiers": { "jsr:@b-fuze/deno-dom@*": "0.1.48", + "jsr:@libs/xml@*": "6.0.1", "npm:hono@*": "4.6.10" }, "jsr": { "@b-fuze/deno-dom@0.1.48": { "integrity": "bf5b591aef2e9e9c59adfcbb93a9ecd45bab5b7c8263625beafa5c8f1662e7da" + }, + "@libs/xml@6.0.1": { + "integrity": "64af4f93464c77c3e1158fb97c3657779ca554b14f38616b96cde31e22d8a309" } }, "npm": { diff --git a/src/services/storage.ts b/src/services/storage.ts index 0a349fb..cc8b88e 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -2,7 +2,7 @@ import { ListObjectsOptions, ListObjectsResult, StorageObject } from "../types/m import { S3, type ListObjectsV2Request } from "https://deno.land/x/aws_api@v0.8.1/services/s3/mod.ts"; import { ApiFactory } from "https://deno.land/x/aws_api@v0.8.1/client/mod.ts"; import { getSignedUrl } from "https://deno.land/x/aws_s3_presign@2.2.1/mod.ts"; -import { DOMParser, Element } from "jsr:@b-fuze/deno-dom"; +import { parse as parseXml } from "jsr:@libs/xml"; interface Credentials { awsAccessKeyId: string; @@ -174,14 +174,10 @@ export class StorageService { } class WebIdentityCredentials { - private parser: DOMParser; - constructor( private roleArn: string, private token: string, - ) { - this.parser = new DOMParser(); - } + ) {} async getCredentials(): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string }> { const params = new URLSearchParams({ @@ -201,20 +197,22 @@ class WebIdentityCredentials { } const xml = await response.text(); - const result = this.parser.parseFromString(xml, "text/xml"); - if (!result) throw new Error("Failed to parse XML response"); + const result = parseXml(xml); - const credentials = result.querySelector("Credentials"); - if (!credentials) throw new Error("No credentials in response"); + const credentials = result.AssumeRoleWithWebIdentityResponse?.Result?.Credentials; + if (!credentials) { + throw new Error("No credentials in response"); + } - const accessKeyId = credentials.querySelector("AccessKeyId")?.textContent; - const secretAccessKey = credentials.querySelector("SecretAccessKey")?.textContent; - const sessionToken = credentials.querySelector("SessionToken")?.textContent; - - if (!accessKeyId || !secretAccessKey || !sessionToken) { + const { AccessKeyId, SecretAccessKey, SessionToken } = credentials; + if (!AccessKeyId || !SecretAccessKey || !SessionToken) { throw new Error("Missing required credential fields in response"); } - return { accessKeyId, secretAccessKey, sessionToken }; + return { + accessKeyId: AccessKeyId, + secretAccessKey: SecretAccessKey, + sessionToken: SessionToken, + }; } } From ecf3d25a97e5d9ee3296e6d9bcf171274938d15d Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:19:53 +0900 Subject: [PATCH 5/6] fix: add lots of logging for auth debugging --- src/services/storage.ts | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/services/storage.ts b/src/services/storage.ts index cc8b88e..2ec4ba2 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -188,24 +188,47 @@ class WebIdentityCredentials { WebIdentityToken: this.token, }); - const response = await fetch(`https://sts.amazonaws.com?${params}`, { + const url = `https://sts.amazonaws.com?${params}`; + const response = await fetch(url, { method: "GET", }); + const responseText = await response.text(); + if (!response.ok) { - throw new Error(`Failed to assume role: ${await response.text()}`); + console.error('IRSA Request Failed:', { + url, + requestHeaders: { + method: "GET", + RoleArn: this.roleArn, + TokenLength: this.token.length, + }, + responseStatus: response.status, + responseHeaders: Object.fromEntries(response.headers), + responseBody: responseText + }); + throw new Error(`Failed to assume role: ${responseText}`); } - const xml = await response.text(); - const result = parseXml(xml); - + const result = parseXml(responseText); const credentials = result.AssumeRoleWithWebIdentityResponse?.Result?.Credentials; + if (!credentials) { - throw new Error("No credentials in response"); + console.error('IRSA Parsing Failed:', { + parsedXml: JSON.stringify(result, null, 2), + rawResponse: responseText + }); + throw new Error("No credentials in response. Full response: " + JSON.stringify(result, null, 2)); } const { AccessKeyId, SecretAccessKey, SessionToken } = credentials; if (!AccessKeyId || !SecretAccessKey || !SessionToken) { + console.error('IRSA Missing Fields:', { + hasAccessKeyId: !!AccessKeyId, + hasSecretAccessKey: !!SecretAccessKey, + hasSessionToken: !!SessionToken, + parsedCredentials: credentials + }); throw new Error("Missing required credential fields in response"); } From 62562c9af6fa880f2f6beca73a45712db451d4e1 Mon Sep 17 00:00:00 2001 From: "Alexander Nicholson 4584443+DragonStuff@users.noreply.github.com" <4584443+DragonStuff@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:25:40 +0900 Subject: [PATCH 6/6] fix: correctly access credentials object --- src/services/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/storage.ts b/src/services/storage.ts index 2ec4ba2..142db68 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -211,7 +211,7 @@ class WebIdentityCredentials { } const result = parseXml(responseText); - const credentials = result.AssumeRoleWithWebIdentityResponse?.Result?.Credentials; + const credentials = result.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult?.Credentials; if (!credentials) { console.error('IRSA Parsing Failed:', {