Skip to content

Commit

Permalink
Renew credentials (#65)
Browse files Browse the repository at this point in the history
* Renew credentials

* Reduce permissions, add tests to CI

* Cleanup package.json

* Update dependencies in package.json

* Add jest command

* Renew URL (todo)

* Configure TS jest

* Add linter exclusion

---------

Co-authored-by: kaklakariada <[email protected]>
  • Loading branch information
kaklakariada and kaklakariada authored Mar 24, 2024
1 parent 3897acb commit 9cabe2c
Show file tree
Hide file tree
Showing 10 changed files with 2,356 additions and 1,249 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ jobs:
defaults:
run:
shell: "bash"
permissions:
contents: read

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -42,6 +44,8 @@ jobs:
run: cd infrastructure && npm ci
- name: Build infrastructure
run: cd infrastructure && npm run cdk synth
- name: Test infrastructure
run: cd infrastructure && npm run test

- name: Configure frontend
run: |
Expand All @@ -62,3 +66,7 @@ jobs:

- name: Build frontend
run: cd frontend && npm run build

- name: Test frontend
if: ${{ false }}
run: cd frontend && npm run test
5 changes: 5 additions & 0 deletions frontend/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
3,363 changes: 2,174 additions & 1,189 deletions frontend/package-lock.json

Large diffs are not rendered by default.

52 changes: 28 additions & 24 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,45 @@
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"jest": "jest",
"cloudfront-deploy": "node deploy/deploy.js",
"deploy": "npm-run-all build cloudfront-deploy"
},
"dependencies": {
"@aws-amplify/ui-react": "^6.1.5",
"@aws-amplify/ui-react": "^6.1.6",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.12",
"@mui/material": "^5.15.12",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@mui/icons-material": "^5.15.13",
"@mui/material": "^5.15.13",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/react-router-dom": "^5.3.3",
"aws-amplify": "^6.0.19",
"aws-sdk": "^2.1574.0",
"aws-amplify": "^6.0.20",
"aws-sdk": "^2.1579.0",
"eslint": "^8.57.0",
"immer": "^10.0.3",
"immer": "^10.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-scripts": "^5.0.1",
"typescript": "^4.9.5",
"util": "^0.12.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"cloudfront-deploy": "node deploy/deploy.js",
"deploy": "npm-run-all build cloudfront-deploy"
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@jest/globals": "^29.7.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.28",
"jest": "^29.7.0",
"npm-run-all": "^4.1.5",
"react-scripts": "^5.0.1",
"ts-jest": "^29.1.2"
},
"eslintConfig": {
"extends": "react-app"
Expand All @@ -44,14 +57,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.25",
"npm-run-all": "^4.1.5"
}
}
21 changes: 18 additions & 3 deletions frontend/src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SkipPreviousIcon from '@mui/icons-material/SkipPrevious';
import IconButton from "@mui/material/IconButton/IconButton";
import { styled } from "@mui/system";
import { PlaylistItem } from "../services/PlaylistService";
import { SignedUrl } from "../services/AuthenticatedS3Client";


const CurrentTrackLink: React.FC<{}> = () => {
Expand Down Expand Up @@ -47,18 +48,32 @@ const PlayerControls: React.FC = () => {
});

async function updateUrl(track: PlaylistItem | undefined) {
if (track && !track.track.isFolder) {
if (track && track.track.isFile) {
const trackUrl = await track.track.getUrl();
console.log(`Current track has changed to ${track.track.fileName}. Playing URL...`);
setUrl(trackUrl);
startPlayingUrl(trackUrl);
} else {
console.log("Invalid item, skip updating url", track);
setUrl(undefined);
}
}

function startPlayingUrl(signedUrl: SignedUrl) {
console.log(`Current track has changed to ${signedUrl.key}. Playing URL...`);
setUrl(signedUrl.url);
signedUrl.whenExpired((newUrl) => {
if (currentTrack?.track.key !== newUrl.key) {
console.log(`URL expired, but track has changed. Skip updating url.`);
return;
}
console.log(`URL expired, updating url for track ${newUrl.key}`);
// also set current playing time to avoid starting from the beginning
//startPlayingUrl(newUrl)
});
}

useEffect(() => {
updateUrl(currentTrack);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTrack]);

function skip(skipSeconds: number) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/MediaList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const MediaList: React.FC<{ bucket: string, path: string, time?: number }> = ({
const { playerControl } = useMusicPlayer();

const isFolder = path === '' || path.indexOf('/') < 0 || path.endsWith('/');
const folderPath = isFolder ? path : path.substr(0, path.lastIndexOf('/') + 1);
const folderPath = isFolder ? path : path.substring(0, path.lastIndexOf('/') + 1);
const currentFolder = s3.getFolder(bucket, folderPath);

useEffect(() => {
Expand Down
54 changes: 52 additions & 2 deletions frontend/src/services/AuthService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { fetchAuthSession, AuthSession, signOut, getCurrentUser, AuthUser } from "aws-amplify/auth";
import { fetchAuthSession, AuthSession, signOut, getCurrentUser, AuthUser } from "aws-amplify/auth";

import { AWSCredentials } from "@aws-amplify/core/internals/utils";

export class AuthService {

async getIdToken(): Promise<string> {
async _getIdToken(): Promise<string> {
const credentails = await this.getAuthSession()
const token = credentails.tokens?.idToken?.toString()
if (!token) {
Expand All @@ -19,7 +21,55 @@ export class AuthService {
return await fetchAuthSession();
}

private async getCredentials(): Promise<AWSCredentials> {
const session = await this.getAuthSession()
if (!session.credentials) {
throw new Error(`Auth session does not contain credentials`);
}
return session.credentials
}
async getRenewableCredentials(): Promise<RenewableCredentials> {
return new RenewableCredentials(this.getCredentials, await this.getCredentials());
}


signOut() {
signOut({ global: true });
}
}

type CredentialsConsumer = (credentials: RenewableCredentials) => void;
type CredentialsProvider = () => Promise<AWSCredentials>;


const CREDENTIAL_RENEWAL_BUFFER_MILLIS = 1000*10;

export class RenewableCredentials {
constructor(private fetchCredentials: CredentialsProvider, private awsCredentials: AWSCredentials) {
const remainingTimeMinutes = (this.expiration.getTime() - Date.now()) / 1000 / 60;
console.log(`Credentials expire at ${this.expiration} in ${remainingTimeMinutes} minutes`)
}
whenExpired(consumer: CredentialsConsumer) {
const expirationMillis = this.expiration.getTime() - Date.now()- CREDENTIAL_RENEWAL_BUFFER_MILLIS;
console.log(`Renewing credentials in ${expirationMillis / 1000/60} minutes`);
setTimeout(async () => {
const newCredentials = await this.fetchCredentials();
consumer(new RenewableCredentials(this.fetchCredentials, newCredentials));
}, expirationMillis);
}

get expiration(): Date {
if (!this.awsCredentials.expiration) {
throw new Error(`Credentials do not have an expiration date`);
}
return this.awsCredentials.expiration;
}

get remainingValidTimeMillis(): number {
return this.expiration.getTime() - Date.now();
}

get credentials(): AWSCredentials {
return this.awsCredentials;
}
}
64 changes: 48 additions & 16 deletions frontend/src/services/AuthenticatedS3Client.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,50 @@
import S3, { ListObjectsV2Request } from "aws-sdk/clients/s3";
import { AuthService } from "./AuthService";
import { AuthService, RenewableCredentials } from "./AuthService";
import environment from '../environment';

interface State {
s3: S3;
credentials: RenewableCredentials;
}

export class SignedUrl {
constructor(public url: string, private operation: string, private bucket: string, public key: string,
private expiration: Date, private s3Client: S3Client) { }

get remainingValidTimeMillis(): number {
return this.expiration.getTime() - Date.now();
}

whenExpired(consumer: (url: SignedUrl) => void) {
const expirationMillis = this.expiration.getTime() - Date.now();
console.log(`Renewing signed url in ${expirationMillis / 1000 / 60} minutes`);
setTimeout(async () => {
const newUrl = await this.s3Client.getSignedUrl(this.operation, this.bucket, this.key);
consumer(newUrl);
}, expirationMillis);
}
}

export class S3Client {
private state: State | undefined;
private authService: AuthService;

constructor(authService: AuthService) {
this.authService = authService;
}
constructor(private authService: AuthService) {
this.state = undefined
}

async getSignedUrl(operation: string, bucket: string, key: string, validForSeconds: number): Promise<string> {
async getSignedUrl(operation: string, bucket: string, key: string): Promise<SignedUrl> {
const state = await this.getState();
const validForSeconds = state.credentials.remainingValidTimeMillis / 1000
//const validForSeconds = 10
const expiration = new Date(Date.now() + validForSeconds * 1000);
const params: any = {
Bucket: bucket,
Key: key,
Expires: validForSeconds
};
return (await this.getS3()).getSignedUrlPromise(operation, params);
console.log(`Getting signed url for ${operation} ${bucket}/${key} valid for ${validForSeconds} seconds`);
const url = await state.s3.getSignedUrlPromise(operation, params);
return new SignedUrl(url, operation, bucket, key, expiration, this);
}

async listBuckets() {
Expand All @@ -31,19 +55,27 @@ export class S3Client {
return (await this.getS3()).listObjectsV2(params).promise();
}

private async createClient(): Promise<State> {
const session = await this.authService.getAuthSession();
private async createState(credentials: RenewableCredentials): Promise<State> {
const s3Config: S3.Types.ClientConfiguration = {
region: environment.region,
credentials: session.credentials
credentials: credentials.credentials
};
return { s3: new S3(s3Config) };
return { s3: new S3(s3Config), credentials };
}

private async getS3(): Promise<S3> {
if (!this.state || !this.state.s3) {
this.state = await this.createClient();
private async getState(): Promise<State> {
if (!this.state) {
const credentials = await this.authService.getRenewableCredentials();
this.state = await this.createState(credentials);
credentials.whenExpired(async (newCredentials) => {
console.log("Creating new S3 client with new credentials");
this.state = await this.createState(newCredentials)
})
}
return this.state.s3;
return this.state;
}

private async getS3(): Promise<S3> {
return (await this.getState()).s3;
}
}
}
Loading

0 comments on commit 9cabe2c

Please sign in to comment.