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
5 changes: 5 additions & 0 deletions server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"schemas": {
"AppPortalAccessIn": {
"properties": {
"application": {
"$ref": "#/components/schemas/ApplicationIn",
"description": "Optionally creates a new application alongside the message.\n\nIf the application id or uid that is used in the path already exists, this argument is ignored.",
"nullable": true
},
"featureFlags": {
"description": "The set of feature flags the created token will have access to.",
"example": [],
Expand Down
49 changes: 43 additions & 6 deletions server/svix-server/src/v1/endpoints/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ use validator::Validate;

use crate::{
core::{permissions, security::generate_app_token, types::FeatureFlagSet},
error::Result,
v1::utils::{api_not_implemented, openapi_tag, ApplicationPath, ValidatedJson},
db::models::application,
error::{HttpError, Result},
v1::{
endpoints::{
application::{create_app_from_app_in, ApplicationIn},
message::validate_create_app_uid,
},
utils::{api_not_implemented, openapi_tag, ApplicationPath, ValidatedJson},
},
AppState,
};

Expand Down Expand Up @@ -45,6 +52,13 @@ pub struct AppPortalAccessIn {
#[serde(default, skip_serializing_if = "FeatureFlagSet::is_empty")]
#[schemars(example = "feature_flag_set_example")]
pub feature_flags: FeatureFlagSet,
/// Optionally creates a new application alongside the message.
///
/// If the application id or uid that is used in the path already exists,
/// this argument is ignored.
#[validate]
#[serde(skip_serializing_if = "Option::is_none")]
pub application: Option<ApplicationIn>,
}

#[derive(Serialize, JsonSchema)]
Expand All @@ -62,11 +76,33 @@ impl From<DashboardAccessOut> for AppPortalAccessOut {
/// Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.
#[aide_annotate(op_id = "v1.authentication.app-portal-access")]
async fn app_portal_access(
State(AppState { cfg, .. }): State<AppState>,
_: Path<ApplicationPath>,
permissions::OrganizationWithApplication { app }: permissions::OrganizationWithApplication,
State(AppState { ref db, cfg, .. }): State<AppState>,
Path(ApplicationPath { app_id }): Path<ApplicationPath>,
permissions::Organization { org_id }: permissions::Organization,
ValidatedJson(data): ValidatedJson<AppPortalAccessIn>,
) -> Result<Json<AppPortalAccessOut>> {
let app_from_path_app_id =
application::Entity::secure_find_by_id_or_uid(org_id.clone(), app_id.to_owned())
.one(db)
.await?;

let app = match (&data.application, app_from_path_app_id) {
(None, None) => {
return Err(
HttpError::not_found(None, Some("Application not found".to_string())).into(),
);
}

(_, Some(app_from_path_param)) => app_from_path_param,
(Some(app_from_body), None) => {
validate_create_app_uid(&app_id, app_from_body)?;
let (app, _metadata) =
create_app_from_app_in(db, app_from_body.to_owned(), org_id).await?;

app
}
};

let token = generate_app_token(
&cfg.jwt_signing_config,
app.org_id,
Expand All @@ -87,14 +123,15 @@ async fn app_portal_access(
async fn dashboard_access(
state: State<AppState>,
path: Path<ApplicationPath>,
permissions: permissions::OrganizationWithApplication,
permissions: permissions::Organization,
) -> Result<Json<DashboardAccessOut>> {
app_portal_access(
state,
path,
permissions,
ValidatedJson(AppPortalAccessIn {
feature_flags: FeatureFlagSet::default(),
application: None,
}),
)
.await
Expand Down
5 changes: 4 additions & 1 deletion server/svix-server/src/v1/endpoints/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,10 @@ pub(crate) async fn create_message_inner(
Ok(msg_out)
}

fn validate_create_app_uid(app_id_or_uid: &ApplicationIdOrUid, data: &ApplicationIn) -> Result<()> {
pub fn validate_create_app_uid(
app_id_or_uid: &ApplicationIdOrUid,
data: &ApplicationIn,
) -> Result<()> {
// If implicit app creation is requested then the UID must be set
// in the request body, and it must match the UID given in the path
if let Some(uid) = &data.uid {
Expand Down
106 changes: 105 additions & 1 deletion server/svix-server/tests/it/e2e_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
//! that the tokens returned by the endpoint have restricted functionality and that the response
//! from the endpoint is valid in the process.

use rand::distributions::DistString;
use reqwest::StatusCode;
use serde::de::IgnoredAny;
use serde_json::Value;
use serde_json::{json, Value};
use svix_server::{
core::{
security::{INVALID_TOKEN_ERR, JWT_SECRET_ERR},
Expand Down Expand Up @@ -154,3 +155,106 @@ async fn test_invalid_auth_error_detail() {
}
}
}

#[tokio::test]
async fn test_app_portal_access_with_application() {
let (client, _jh) = start_svix_server().await;

let app_uid = format!(
"app-created-in-portal-{}",
rand::distributions::Alphanumeric.sample_string(&mut rand::thread_rng(), 15)
);

// app-portal-access without the application field fails
let _: IgnoredAny = client
.post(
&format!("api/v1/auth/app-portal-access/{app_uid}/"),
json!({
"featureFlags": []
}),
StatusCode::NOT_FOUND,
)
.await
.unwrap();

// app-portal-access with application
let _: IgnoredAny = client
.post(
&format!("api/v1/auth/app-portal-access/{app_uid}/"),
json!({
"featureFlags": [],
"application": {
"name": "Test App Created With Portal Access",
"uid": app_uid,
}
}),
StatusCode::OK,
)
.await
.unwrap();

// app was created
let app: serde_json::Value = client
.get(&format!("api/v1/app/{app_uid}/"), StatusCode::OK)
.await
.unwrap();

assert_eq!(app["uid"], app_uid);
assert_eq!(app["name"], "Test App Created With Portal Access");

// Access portal again with application field - should be ignored since app exists
let _: IgnoredAny = client
.post(
&format!("api/v1/auth/app-portal-access/{app_uid}/"),
json!({
"featureFlags": [],
"application": {
"name": "Updated name will be ignored",
"uid": app_uid,
}
}),
StatusCode::OK,
)
.await
.unwrap();

// Verify the app name didn't change
let app_after: serde_json::Value = client
.get(&format!("api/v1/app/{app_uid}/"), StatusCode::OK)
.await
.unwrap();

assert_eq!(app_after["name"], "Test App Created With Portal Access");

// UID in path must match UID in body
let _: IgnoredAny = client
.post(
"api/v1/auth/app-portal-access/different-uid/",
json!({
"featureFlags": [],
"application": {
"name": "Test App",
"uid": app_uid, // This doesn't match the path
}
}),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await
.unwrap();

// UID must be set in body when creating
let _: IgnoredAny = client
.post(
"api/v1/auth/app-portal-access/new-app-uid/",
json!({
"featureFlags": [],
"application": {
"name": "Test App Without UID",
// Missing uid field
}
}),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await
.unwrap();
}
5 changes: 4 additions & 1 deletion server/svix-server/tests/it/utils/common_calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,10 @@ pub async fn app_portal_access(
let resp: DashboardAccessOut = org_client
.post(
&format!("api/v1/auth/app-portal-access/{application_id}/"),
AppPortalAccessIn { feature_flags },
AppPortalAccessIn {
feature_flags,
application: None,
},
StatusCode::OK,
)
.await
Expand Down
Loading