Skip to main content

cloudillo_idp/
api_keys.rs

1//! API Key management endpoints for Identity Provider
2
3use axum::{
4	extract::{Path, Query, State},
5	http::StatusCode,
6	Json,
7};
8use serde::{Deserialize, Serialize};
9
10use cloudillo_core::extract::{IdTag, OptionalRequestId};
11use cloudillo_types::identity_provider_adapter::{ApiKey, CreateApiKeyOptions, ListApiKeyOptions};
12use cloudillo_types::types::{
13	serialize_timestamp_iso, serialize_timestamp_iso_opt, ApiResponse, Timestamp,
14};
15
16use crate::prelude::*;
17
18/// Helper function to split id_tag into (prefix, domain) using the tenant domain
19fn split_id_tag_with_tenant(id_tag: &str, tenant_domain: &str) -> ClResult<(String, String)> {
20	let expected_suffix = format!(".{}", tenant_domain);
21	if id_tag.ends_with(&expected_suffix) {
22		let prefix = id_tag[..id_tag.len() - expected_suffix.len()].to_string();
23		if !prefix.is_empty() {
24			Ok((prefix, tenant_domain.to_string()))
25		} else {
26			Err(Error::ValidationError("Invalid id_tag: prefix cannot be empty".to_string()))
27		}
28	} else {
29		Err(Error::ValidationError(format!(
30			"Identity {} does not belong to this IDP domain {}",
31			id_tag, tenant_domain
32		)))
33	}
34}
35
36/// Response structure for API key details (metadata only, no plaintext key)
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ApiKeyResponse {
40	pub id: i32,
41	pub id_tag: String,
42	pub key_prefix: String,
43	#[serde(skip_serializing_if = "Option::is_none")]
44	pub name: Option<String>,
45	#[serde(serialize_with = "serialize_timestamp_iso")]
46	pub created_at: Timestamp,
47	#[serde(
48		skip_serializing_if = "Option::is_none",
49		serialize_with = "serialize_timestamp_iso_opt"
50	)]
51	pub last_used_at: Option<Timestamp>,
52	#[serde(
53		skip_serializing_if = "Option::is_none",
54		serialize_with = "serialize_timestamp_iso_opt"
55	)]
56	pub expires_at: Option<Timestamp>,
57}
58
59impl From<ApiKey> for ApiKeyResponse {
60	fn from(key: ApiKey) -> Self {
61		// Reconstruct id_tag from prefix and domain
62		let id_tag = format!("{}.{}", key.id_tag_prefix, key.id_tag_domain);
63		Self {
64			id: key.id,
65			id_tag,
66			key_prefix: key.key_prefix.to_string(),
67			name: key.name.clone(),
68			created_at: key.created_at,
69			last_used_at: key.last_used_at,
70			expires_at: key.expires_at,
71		}
72	}
73}
74
75/// Response structure for newly created API key (includes plaintext key, shown once)
76#[derive(Debug, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct CreatedApiKeyResponse {
79	pub api_key: ApiKeyResponse,
80	pub plaintext_key: String,
81}
82
83/// Request structure for creating a new API key
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct CreateApiKeyRequest {
87	/// The identity id_tag to create the API key for
88	pub id_tag: String,
89	/// Human-readable name for the API key
90	pub name: Option<String>,
91	/// Expiration timestamp (optional, defaults to no expiration)
92	pub expires_at: Option<i64>,
93}
94
95/// Query parameters for listing API keys
96#[derive(Debug, Deserialize, Default)]
97#[serde(rename_all = "camelCase")]
98pub struct ListApiKeysQuery {
99	/// The identity id_tag to list API keys for
100	pub id_tag: Option<String>,
101	/// Limit results
102	pub limit: Option<u32>,
103	/// Offset for pagination
104	pub offset: Option<u32>,
105}
106
107/// POST /api/api-keys - Create a new API key for a specified identity
108#[axum::debug_handler]
109pub async fn create_api_key(
110	State(app): State<App>,
111	tn_id: TnId,
112	IdTag(_auth_id_tag): IdTag,
113	OptionalRequestId(req_id): OptionalRequestId,
114	Json(create_req): Json<CreateApiKeyRequest>,
115) -> ClResult<(StatusCode, Json<ApiResponse<CreatedApiKeyResponse>>)> {
116	// Get the tenant domain (IDP domain)
117	let tenant_domain = app.auth_adapter.read_id_tag(tn_id).await?;
118
119	// The id_tag from request must end with the tenant domain
120	let expected_suffix = format!(".{}", tenant_domain);
121	if !create_req.id_tag.ends_with(&expected_suffix) {
122		return Err(Error::ValidationError(format!(
123			"Identity {} does not belong to this IDP domain {}",
124			create_req.id_tag, tenant_domain
125		)));
126	}
127
128	// Extract prefix by removing the domain suffix
129	let id_tag_prefix =
130		create_req.id_tag[..create_req.id_tag.len() - expected_suffix.len()].to_string();
131	let id_tag_domain = tenant_domain.to_string();
132
133	info!(
134		id_tag_prefix = %id_tag_prefix,
135		id_tag_domain = %id_tag_domain,
136		name = ?create_req.name,
137		"POST /api/api-keys - Creating new API key"
138	);
139
140	// Verify Identity Provider adapter is available
141	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
142		"Identity Provider not available on this instance".to_string(),
143	))?;
144
145	// Validate expiration if provided
146	if let Some(expires_timestamp) = create_req.expires_at {
147		let expiration = Timestamp(expires_timestamp);
148		if expiration.0 <= Timestamp::now().0 {
149			return Err(Error::ValidationError(
150				"Expiration time must be in the future".to_string(),
151			));
152		}
153	}
154
155	// Create the API key with split id_tag components
156	let opts = CreateApiKeyOptions {
157		id_tag_prefix: &id_tag_prefix,
158		id_tag_domain: &id_tag_domain,
159		name: create_req.name.as_deref(),
160		expires_at: create_req.expires_at.map(Timestamp),
161	};
162
163	let created = idp_adapter.create_api_key(opts).await.map_err(|e| {
164		warn!("Failed to create API key: {}", e);
165		e
166	})?;
167
168	let response_data = CreatedApiKeyResponse {
169		api_key: ApiKeyResponse::from(created.api_key),
170		plaintext_key: created.plaintext_key,
171	};
172
173	let mut response = ApiResponse::new(response_data);
174	if let Some(id) = req_id {
175		response = response.with_req_id(id);
176	}
177
178	Ok((StatusCode::CREATED, Json(response)))
179}
180
181/// GET /api/api-keys - List API keys for a specified identity
182#[axum::debug_handler]
183pub async fn list_api_keys(
184	State(app): State<App>,
185	tn_id: TnId,
186	IdTag(_auth_id_tag): IdTag,
187	Query(query_params): Query<ListApiKeysQuery>,
188	OptionalRequestId(req_id): OptionalRequestId,
189) -> ClResult<(StatusCode, Json<ApiResponse<Vec<ApiKeyResponse>>>)> {
190	// id_tag is required to list API keys
191	let id_tag = query_params
192		.id_tag
193		.as_ref()
194		.ok_or(Error::ValidationError("id_tag query parameter is required".to_string()))?;
195
196	// Get the tenant domain (IDP domain)
197	let tenant_domain = app.auth_adapter.read_id_tag(tn_id).await?;
198
199	// Split id_tag using tenant domain
200	let (id_tag_prefix, id_tag_domain) = split_id_tag_with_tenant(id_tag, &tenant_domain)?;
201
202	info!(
203		id_tag_prefix = %id_tag_prefix,
204		id_tag_domain = %id_tag_domain,
205		"GET /api/api-keys - Listing API keys"
206	);
207
208	// Verify Identity Provider adapter is available
209	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
210		"Identity Provider not available on this instance".to_string(),
211	))?;
212
213	let opts = ListApiKeyOptions {
214		id_tag_prefix: Some(id_tag_prefix),
215		id_tag_domain: Some(id_tag_domain),
216		limit: query_params.limit,
217		offset: query_params.offset,
218	};
219
220	let keys = idp_adapter.list_api_keys(opts).await?;
221
222	let response_data: Vec<ApiKeyResponse> = keys.into_iter().map(ApiKeyResponse::from).collect();
223
224	let total = response_data.len();
225	let offset = query_params.offset.unwrap_or(0) as usize;
226	let limit = query_params.limit.unwrap_or(20) as usize;
227	let mut response = ApiResponse::with_pagination(response_data, offset, limit, total);
228	if let Some(id) = req_id {
229		response = response.with_req_id(id);
230	}
231
232	Ok((StatusCode::OK, Json(response)))
233}
234
235/// Query parameters for get/delete API key endpoints
236#[derive(Debug, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct ApiKeyIdTagQuery {
239	/// The identity id_tag the API key belongs to
240	pub id_tag: String,
241}
242
243/// GET /api/idp/api-keys/{api_key_id} - Get a specific API key by ID
244#[axum::debug_handler]
245pub async fn get_api_key(
246	State(app): State<App>,
247	tn_id: TnId,
248	IdTag(_auth_id_tag): IdTag,
249	Path(api_key_id): Path<i32>,
250	Query(query): Query<ApiKeyIdTagQuery>,
251	OptionalRequestId(req_id): OptionalRequestId,
252) -> ClResult<(StatusCode, Json<ApiResponse<ApiKeyResponse>>)> {
253	// Get the tenant domain (IDP domain)
254	let tenant_domain = app.auth_adapter.read_id_tag(tn_id).await?;
255
256	// Split id_tag using tenant domain
257	let (id_tag_prefix, id_tag_domain) = split_id_tag_with_tenant(&query.id_tag, &tenant_domain)?;
258
259	info!(
260		api_key_id = %api_key_id,
261		id_tag_prefix = %id_tag_prefix,
262		id_tag_domain = %id_tag_domain,
263		"GET /api/idp/api-keys/:api_key_id - Getting API key"
264	);
265
266	// Verify Identity Provider adapter is available
267	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
268		"Identity Provider not available on this instance".to_string(),
269	))?;
270
271	// List all keys for this identity and find the one with matching ID
272	let opts = ListApiKeyOptions {
273		id_tag_prefix: Some(id_tag_prefix),
274		id_tag_domain: Some(id_tag_domain),
275		limit: None,
276		offset: None,
277	};
278
279	let keys = idp_adapter.list_api_keys(opts).await?;
280	let key = keys.into_iter().find(|k| k.id == api_key_id).ok_or(Error::NotFound)?;
281
282	let response_data = ApiKeyResponse::from(key);
283	let mut response = ApiResponse::new(response_data);
284	if let Some(id) = req_id {
285		response = response.with_req_id(id);
286	}
287
288	Ok((StatusCode::OK, Json(response)))
289}
290
291/// DELETE /api/idp/api-keys/{api_key_id} - Revoke/delete an API key
292#[axum::debug_handler]
293pub async fn delete_api_key(
294	State(app): State<App>,
295	tn_id: TnId,
296	IdTag(_auth_id_tag): IdTag,
297	Path(api_key_id): Path<i32>,
298	Query(query): Query<ApiKeyIdTagQuery>,
299	OptionalRequestId(req_id): OptionalRequestId,
300) -> ClResult<(StatusCode, Json<ApiResponse<()>>)> {
301	// Get the tenant domain (IDP domain)
302	let tenant_domain = app.auth_adapter.read_id_tag(tn_id).await?;
303
304	// Split id_tag using tenant domain
305	let (id_tag_prefix, id_tag_domain) = split_id_tag_with_tenant(&query.id_tag, &tenant_domain)?;
306
307	info!(
308		api_key_id = %api_key_id,
309		id_tag_prefix = %id_tag_prefix,
310		id_tag_domain = %id_tag_domain,
311		"DELETE /api/idp/api-keys/:api_key_id - Deleting API key"
312	);
313
314	// Verify Identity Provider adapter is available
315	let idp_adapter = app.idp_adapter.as_ref().ok_or(Error::ServiceUnavailable(
316		"Identity Provider not available on this instance".to_string(),
317	))?;
318
319	// Use the ownership-scoped deletion to ensure the key belongs to this identity
320	let deleted = idp_adapter
321		.delete_api_key_for_identity(api_key_id, &id_tag_prefix, &id_tag_domain)
322		.await
323		.map_err(|e| {
324			warn!("Failed to delete API key: {}", e);
325			e
326		})?;
327
328	if !deleted {
329		warn!(
330			api_key_id = %api_key_id,
331			id_tag_prefix = %id_tag_prefix,
332			id_tag_domain = %id_tag_domain,
333			"Attempted to delete non-existent or unowned API key"
334		);
335		return Err(Error::NotFound);
336	}
337
338	let mut response = ApiResponse::new(());
339	if let Some(id) = req_id {
340		response = response.with_req_id(id);
341	}
342
343	Ok((StatusCode::OK, Json(response)))
344}
345
346// vim: ts=4