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
124 changes: 116 additions & 8 deletions app/backend/src/api_impl/api/auth.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
use anyhow::{Error, Result, anyhow};
use aws_sdk_cognitoidentityprovider::{
error::SdkError, operation::admin_initiate_auth::AdminInitiateAuthError, types::AuthFlowType,
};
use aws_sdk_cognitoidentityprovider::error::SdkError;
use aws_sdk_cognitoidentityprovider::operation::admin_initiate_auth::AdminInitiateAuthError;
use aws_sdk_cognitoidentityprovider::operation::admin_user_global_sign_out::AdminUserGlobalSignOutError;
use aws_sdk_cognitoidentityprovider::operation::get_tokens_from_refresh_token::GetTokensFromRefreshTokenError;
use aws_sdk_cognitoidentityprovider::types::AuthFlowType;
use axum_extra::extract::{CookieJar, Host};
use http::Method;
use openapi::{
apis::auth::{Auth, AuthLoginPostResponse},
models::{AuthLoginPost200Response, AuthLoginPostRequest},
use openapi::apis::auth::{
Auth, AuthLoginPostResponse, AuthRefreshPostResponse, AuthSignoutPostResponse,
};
use openapi::models::{
AuthLoginPost200Response, AuthLoginPostRequest, AuthRefreshPost200Response,
AuthRefreshPostRequest, AuthSignoutPostRequest,
};

use crate::api_impl::api::ApiImpl;
Expand Down Expand Up @@ -60,7 +65,7 @@ impl Auth<Error> for ApiImpl {
"Authentication result missing from Cognito response"
))?;

let token = auth_result
let access_token = auth_result
.access_token()
.map(|at| at.to_string())
.ok_or(anyhow!("Access token missing from Cognito response"))?;
Expand All @@ -70,8 +75,111 @@ impl Auth<Error> for ApiImpl {
.map(|id| id.to_string())
.ok_or(anyhow!("ID token missing from Cognito response"))?;

let refresh_token = auth_result
.refresh_token()
.map(|rt| rt.to_string())
.ok_or(anyhow!("Refresh token missing from Cognito response"))?;

Ok(AuthLoginPostResponse::Status200_LoginSuccessful(
AuthLoginPost200Response { token, user_id },
AuthLoginPost200Response {
access_token,
refresh_token,
user_id,
},
))
}

async fn auth_refresh_post(
&self,
_method: &Method,
_host: &Host,
_cookies: &CookieJar,
body: &AuthRefreshPostRequest,
) -> Result<AuthRefreshPostResponse> {
let tokens_refresh_result = self
.cognito_client
.get_tokens_from_refresh_token()
.client_id(&self.cognito_client_id)
.refresh_token(&body.refresh_token)
.send()
.await;

let refreshed_tokens_output = match tokens_refresh_result {
Ok(output) => output,
Err(SdkError::ServiceError(err))
if matches!(
err.err(),
GetTokensFromRefreshTokenError::NotAuthorizedException { .. }
| GetTokensFromRefreshTokenError::RefreshTokenReuseException { .. }
| GetTokensFromRefreshTokenError::UserNotFoundException { .. }
) =>
{
return Ok(
AuthRefreshPostResponse::Status401_InvalidOrExpiredRefreshToken(
"Invalid or expired refresh token".to_string(),
),
);
}
Err(err) => {
return Err(anyhow!("Error during Cognito token refresh: {:?}", err));
}
};

let auth_result = refreshed_tokens_output
.authentication_result()
.ok_or(anyhow!(
"Authentication result missing from Cognito response during token refresh"
))?;

let access_token = auth_result
.access_token()
.map(|at| at.to_string())
.ok_or(anyhow!(
"Access token missing from Cognito response during token refresh"
))?;

let refresh_token = auth_result
.refresh_token()
.map(|rt| rt.to_string())
.ok_or(anyhow!(
"Refresh token missing from Cognito response during token refresh"
))?;

Ok(AuthRefreshPostResponse::Status200_TokenRefreshSuccessful(
AuthRefreshPost200Response {
access_token,
refresh_token,
},
))
}

async fn auth_signout_post(
&self,
_method: &Method,
_host: &Host,
_cookies: &CookieJar,
body: &AuthSignoutPostRequest,
) -> Result<AuthSignoutPostResponse> {
let signout_result = self
.cognito_client
.admin_user_global_sign_out()
.username(&body.email)
.user_pool_id(&self.cognito_user_pool_id)
.send()
.await;

match signout_result {
Ok(_) => Ok(AuthSignoutPostResponse::Status200_SignOutSuccessful),
Err(SdkError::ServiceError(err))
if matches!(
err.err(),
AdminUserGlobalSignOutError::UserNotFoundException { .. }
| AdminUserGlobalSignOutError::NotAuthorizedException { .. }
) =>
{
Ok(AuthSignoutPostResponse::Status401_Unauthorized)
}
Err(err) => Err(anyhow!("Error during Cognito sign out: {:?}", err)),
}
}
}
34 changes: 17 additions & 17 deletions app/deployment/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/deployment/lib/deployment-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class HeartOfTheValleyStack extends cdk.Stack {
authFlows: {
adminUserPassword: true,
},
refreshTokenRotationGracePeriod: cdk.Duration.seconds(0),
});

const apiHandler = new cdk.aws_lambda.Function(
Expand Down Expand Up @@ -69,6 +70,7 @@ export class HeartOfTheValleyStack extends cdk.Stack {

table.grantReadWriteData(apiHandler);
adminUserPool.grant(apiHandler, "cognito-idp:AdminInitiateAuth");
adminUserPool.grant(apiHandler, "cognito-idp:AdminUserGlobalSignOut");

const frontendBucket = new cdk.aws_s3.Bucket(
this,
Expand Down
6 changes: 3 additions & 3 deletions app/deployment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
"@types/bun": "^1.2.20",
"@types/node": "22.7.9",
"@types/yargs": "^17.0.33",
"aws-cdk": "^2.1029.2",
"aws-cdk": "^2.1108.0",
"typescript": "~5.6.3",
"yargs": "^18.0.0"
},
"dependencies": {
"aws-cdk-lib": "2.208.0",
"constructs": "^10.0.0"
"aws-cdk-lib": "^2.240.0",
"constructs": "^10.4.2"
},
"type": "module"
}
4 changes: 4 additions & 0 deletions openapi-spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ components:
paths:
/auth/login:
$ref: "./paths/auth/login.yaml"
/auth/refresh:
$ref: "./paths/auth/refresh.yaml"
/auth/signout:
$ref: "./paths/auth/signout.yaml"
/features:
$ref: "./paths/features.yaml"
/features/{featureId}:
Expand Down
16 changes: 7 additions & 9 deletions openapi-spec/paths/auth/login.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,19 @@ post:
schema:
type: object
properties:
token:
accessToken:
type: string
description: Authentication token
description: JWT access token for authenticated requests
refreshToken:
type: string
description: JWT refresh token for obtaining new access tokens
userId:
type: string
description: ID of the authenticated user
required:
- token
- accessToken
- refreshToken
- userId
"400":
description: Bad request
content:
text/plain:
schema:
type: string
"401":
description: Invalid credentials
content:
Expand Down
42 changes: 42 additions & 0 deletions openapi-spec/paths/auth/refresh.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
post:
summary: Refresh authentication token
description: Get a new authentication token using a valid refresh token
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
refreshToken:
type: string
description: Valid refresh token
required:
- refreshToken
responses:
"200":
description: Token refresh successful
content:
application/json:
schema:
type: object
properties:
accessToken:
type: string
description: New JWT access token for authenticated requests
refreshToken:
type: string
description: New JWT refresh token for obtaining new access tokens
required:
- accessToken
- refreshToken
"401":
description: Invalid or expired refresh token
content:
text/plain:
schema:
type: string
"500":
description: Internal server error
25 changes: 25 additions & 0 deletions openapi-spec/paths/auth/signout.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
post:
summary: User sign out
description: Sign out the current user and invalidate their authentication tokens
tags:
- auth
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
format: email
description: User email address to sign out
required:
- email
required: true
responses:
"200":
description: Sign out successful
"401":
description: Unauthorized
"500":
description: Internal server error