diff --git a/include/fluent-bit/flb_oauth2.h b/include/fluent-bit/flb_oauth2.h index 62777abcfe5..94374660bf5 100644 --- a/include/fluent-bit/flb_oauth2.h +++ b/include/fluent-bit/flb_oauth2.h @@ -42,6 +42,7 @@ struct flb_oauth2_config { flb_sds_t token_url; flb_sds_t client_id; flb_sds_t client_secret; + flb_sds_t client_secret_file; flb_sds_t user_agent; flb_sds_t scope; flb_sds_t audience; diff --git a/plugins/out_http/http.c b/plugins/out_http/http.c index 79f7d9dfa1a..caae3f03229 100644 --- a/plugins/out_http/http.c +++ b/plugins/out_http/http.c @@ -743,6 +743,11 @@ static struct flb_config_map config_map[] = { 0, FLB_TRUE, offsetof(struct flb_out_http, oauth2_config.client_secret), "OAuth2 client_secret" }, + { + FLB_CONFIG_MAP_STR, "oauth2.client_secret_file", NULL, + 0, FLB_TRUE, offsetof(struct flb_out_http, oauth2_config.client_secret_file), + "Optional OAuth2 client_secret file path" + }, { FLB_CONFIG_MAP_STR, "oauth2.user_agent", NULL, 0, FLB_TRUE, offsetof(struct flb_out_http, oauth2_config.user_agent), diff --git a/plugins/out_http/http_conf.c b/plugins/out_http/http_conf.c index 47d39c9fc91..669831864ff 100644 --- a/plugins/out_http/http_conf.c +++ b/plugins/out_http/http_conf.c @@ -367,8 +367,10 @@ struct flb_out_http *flb_http_conf_create(struct flb_output_instance *ins, return NULL; } } - else if (!ctx->oauth2_config.client_secret) { - flb_plg_error(ctx->ins, "oauth2 basic/post require client_secret"); + else if (!ctx->oauth2_config.client_secret && + !ctx->oauth2_config.client_secret_file) { + flb_plg_error(ctx->ins, "oauth2 basic/post require client_secret " + "or client_secret_file"); flb_http_conf_destroy(ctx); return NULL; } diff --git a/plugins/out_opentelemetry/opentelemetry_conf.c b/plugins/out_opentelemetry/opentelemetry_conf.c index c09cbf9eb2a..e5170f6c654 100644 --- a/plugins/out_opentelemetry/opentelemetry_conf.c +++ b/plugins/out_opentelemetry/opentelemetry_conf.c @@ -449,8 +449,10 @@ struct opentelemetry_context *flb_opentelemetry_context_create(struct flb_output return NULL; } } - else if (!ctx->oauth2_config.client_secret) { - flb_plg_error(ctx->ins, "oauth2 basic/post require client_secret"); + else if (!ctx->oauth2_config.client_secret && + !ctx->oauth2_config.client_secret_file) { + flb_plg_error(ctx->ins, "oauth2 basic/post require client_secret " + "or client_secret_file"); flb_opentelemetry_context_destroy(ctx); return NULL; } diff --git a/src/flb_oauth2.c b/src/flb_oauth2.c index 90e63ecae58..2c1d0cfe76e 100644 --- a/src/flb_oauth2.c +++ b/src/flb_oauth2.c @@ -67,6 +67,11 @@ struct flb_config_map oauth2_config_map[] = { 0, FLB_TRUE, offsetof(struct flb_oauth2_config, client_secret), "OAuth2 client_secret" }, + { + FLB_CONFIG_MAP_STR, "oauth2.client_secret_file", NULL, + 0, FLB_TRUE, offsetof(struct flb_oauth2_config, client_secret_file), + "Optional OAuth2 client_secret file path" + }, { FLB_CONFIG_MAP_STR, "oauth2.user_agent", NULL, 0, FLB_TRUE, offsetof(struct flb_oauth2_config, user_agent), @@ -190,6 +195,7 @@ static void oauth2_apply_defaults(struct flb_oauth2_config *cfg) cfg->token_url = NULL; cfg->client_id = NULL; cfg->client_secret = NULL; + cfg->client_secret_file = NULL; cfg->user_agent = NULL; cfg->scope = NULL; cfg->audience = NULL; @@ -249,6 +255,15 @@ static int oauth2_clone_config(struct flb_oauth2_config *dst, } } + if (src->client_secret_file) { + dst->client_secret_file = flb_sds_create(src->client_secret_file); + if (!dst->client_secret_file) { + flb_errno(); + flb_oauth2_config_destroy(dst); + return -1; + } + } + if (src->user_agent) { dst->user_agent = flb_sds_create(src->user_agent); if (!dst->user_agent) { @@ -339,6 +354,8 @@ void flb_oauth2_config_destroy(struct flb_oauth2_config *cfg) cfg->client_id = NULL; flb_sds_destroy(cfg->client_secret); cfg->client_secret = NULL; + flb_sds_destroy(cfg->client_secret_file); + cfg->client_secret_file = NULL; flb_sds_destroy(cfg->user_agent); cfg->user_agent = NULL; flb_sds_destroy(cfg->scope); @@ -1175,11 +1192,95 @@ static int oauth2_http_request(struct flb_oauth2 *ctx, flb_sds_t body) return -1; } +/* + * Read the client_secret from a file, trimming trailing CR/LF characters. + * Returns 0 and a newly-allocated flb_sds_t in *out on success, -1 otherwise. + */ +static int oauth2_read_secret_file(const char *path, flb_sds_t *out) +{ + int ret; + size_t len; + size_t size = 0; + char *buf = NULL; + + ret = flb_utils_read_file((char *) path, &buf, &size); + if (ret != 0 || !buf) { + flb_error("[oauth2] cannot read client_secret_file '%s'", path); + return -1; + } + + /* trim trailing new lines */ + for (len = size; len > 0; len--) { + if (buf[len - 1] != '\n' && buf[len - 1] != '\r') { + break; + } + } + + if (len == 0) { + flb_free(buf); + flb_error("[oauth2] client_secret_file '%s' is empty", path); + return -1; + } + + *out = flb_sds_create_len(buf, len); + flb_free(buf); + + if (!*out) { + flb_errno(); + return -1; + } + + return 0; +} + +/* + * Load client_secret from client_secret_file, replacing the current value. The + * file is re-read on every refresh so a rotated secret is always picked up, + * independent of filesystem mtime resolution. No-op for auth methods that do + * not use client_secret, or when client_secret_file is unset. + */ +static int oauth2_refresh_client_secret_from_file(struct flb_oauth2 *ctx) +{ + int ret; + flb_sds_t secret = NULL; + + if (ctx->cfg.auth_method != FLB_OAUTH2_AUTH_METHOD_BASIC && + ctx->cfg.auth_method != FLB_OAUTH2_AUTH_METHOD_POST) { + return 0; + } + + if (!ctx->cfg.client_secret_file) { + return 0; + } + + ret = oauth2_read_secret_file(ctx->cfg.client_secret_file, &secret); + if (ret != 0) { + return -1; + } + + if (ctx->cfg.client_secret) { + flb_sds_destroy(ctx->cfg.client_secret); + } + ctx->cfg.client_secret = secret; + + return 0; +} + static int oauth2_refresh_locked(struct flb_oauth2 *ctx) { int ret; flb_sds_t body; + /* + * Pick up a rotated secret file before building the request so both the + * POST body and the BASIC auth header use the current value. + */ + ret = oauth2_refresh_client_secret_from_file(ctx); + if (ret != 0) { + flb_error("[oauth2] could not refresh client_secret from file"); + return -1; + } + body = oauth2_build_body(ctx); if (!body) { flb_error("[oauth2] could not build request body"); @@ -1274,6 +1375,27 @@ struct flb_oauth2 *flb_oauth2_create_from_config(struct flb_config *config, return NULL; } } + + /* + * When a client_secret_file is configured, load it now so failures are + * surfaced at context creation. If both client_secret and + * client_secret_file are set, the file takes precedence. + */ + if (ctx->cfg.client_secret_file) { + if (ctx->cfg.client_secret) { + flb_debug("[oauth2] both client_secret and client_secret_file are " + "set; client_secret_file takes precedence"); + } + + ret = oauth2_refresh_client_secret_from_file(ctx); + if (ret != 0) { + flb_error("[oauth2] failed to load client_secret_file '%s'", + ctx->cfg.client_secret_file); + flb_oauth2_destroy(ctx); + return NULL; + } + } + ctx->auth_url = flb_sds_create(ctx->cfg.token_url); if (!ctx->auth_url) { flb_errno(); diff --git a/tests/internal/oauth2.c b/tests/internal/oauth2.c index 9df0817d821..7e9da37530d 100644 --- a/tests/internal/oauth2.c +++ b/tests/internal/oauth2.c @@ -30,6 +30,7 @@ #define MOCK_BODY_SIZE 16384 #define TEST_CERT_FILENAME "oauth2_private_key_jwt_test_cert.pem" #define TEST_KEY_FILENAME "oauth2_private_key_jwt_test_key.pem" +#define TEST_SECRET_FILENAME "oauth2_client_secret_file_test.txt" static const char *TEST_PRIVATE_KEY_PEM = "-----BEGIN PRIVATE KEY-----\n" @@ -547,6 +548,36 @@ static struct flb_oauth2 *create_oauth_ctx(struct flb_config *config, return create_oauth_ctx_with_user_agent(config, server, refresh_skew, NULL); } +static struct flb_oauth2 *create_oauth_ctx_secret_file(struct flb_config *config, + struct oauth2_mock_server *server, + const char *secret_file, + const char *inline_secret) +{ + struct flb_oauth2_config cfg; + struct flb_oauth2 *ctx; + + memset(&cfg, 0, sizeof(cfg)); + cfg.enabled = FLB_TRUE; + cfg.token_url = flb_sds_create_size(64); + cfg.auth_method = FLB_OAUTH2_AUTH_METHOD_POST; + cfg.refresh_skew = 58; + cfg.client_id = flb_sds_create("id"); + if (inline_secret) { + cfg.client_secret = flb_sds_create(inline_secret); + } + if (secret_file) { + cfg.client_secret_file = flb_sds_create(secret_file); + } + + flb_sds_printf(&cfg.token_url, "http://127.0.0.1:%d/token", server->port); + + ctx = flb_oauth2_create_from_config(config, &cfg); + + flb_oauth2_config_destroy(&cfg); + + return ctx; +} + void test_user_agent_header_optional(void) { int ret; @@ -640,6 +671,27 @@ static int write_text_file(const char *path, const char *content) return 0; } +static int test_secret_file_path(char *path, size_t path_size) +{ + char *tmpdir; + int ret; + + tmpdir = flb_test_env_tmpdir(); + TEST_CHECK(tmpdir != NULL); + if (!tmpdir) { + return -1; + } + + ret = snprintf(path, path_size, "%s/%s.%d", + tmpdir, TEST_SECRET_FILENAME, (int) getpid()); + flb_free(tmpdir); + if (ret < 0 || (size_t) ret >= path_size) { + return -1; + } + + return 0; +} + static int test_setup_private_key_jwt_files(char *key_path, size_t key_path_size, char *cert_path, size_t cert_path_size) { @@ -1251,6 +1303,213 @@ void test_private_key_jwt_x5t_header(void) flb_config_exit(config); } +void test_client_secret_from_file(void) +{ + int ret; + char secret_path[256]; + char value[128]; + flb_sds_t token = NULL; + struct flb_config *config; + struct flb_oauth2 *ctx; + struct oauth2_mock_server server; + + config = flb_config_init(); + TEST_CHECK(config != NULL); + + ret = test_secret_file_path(secret_path, sizeof(secret_path)); + TEST_CHECK(ret == 0); + + ret = write_text_file(secret_path, "filesecret\n"); + TEST_CHECK(ret == 0); + + ret = oauth2_mock_server_start(&server, 3600, 0); + TEST_CHECK(ret == 0); + + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, NULL); + TEST_CHECK(ctx != NULL); + +#ifdef FLB_SYSTEM_MACOS + ret = oauth2_mock_server_wait_ready(&server); + TEST_CHECK(ret == 0); +#endif + + ret = flb_oauth2_get_access_token(ctx, &token, FLB_FALSE); + TEST_CHECK(ret == 0); + TEST_CHECK(token != NULL); + + ret = extract_form_value(server.latest_token_request, "client_secret", + value, sizeof(value)); + TEST_CHECK(ret == 0); + TEST_CHECK(strcmp(value, "filesecret") == 0); + + flb_oauth2_destroy(ctx); + oauth2_mock_server_stop(&server); + unlink(secret_path); + flb_config_exit(config); +} + +void test_client_secret_file_change_detection(void) +{ + int ret; + char secret_path[256]; + char value[128]; + flb_sds_t token = NULL; + struct flb_config *config; + struct flb_oauth2 *ctx; + struct oauth2_mock_server server; + + config = flb_config_init(); + TEST_CHECK(config != NULL); + + ret = test_secret_file_path(secret_path, sizeof(secret_path)); + TEST_CHECK(ret == 0); + + ret = write_text_file(secret_path, "secret-v1\n"); + TEST_CHECK(ret == 0); + + ret = oauth2_mock_server_start(&server, 3600, 0); + TEST_CHECK(ret == 0); + + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, NULL); + TEST_CHECK(ctx != NULL); + +#ifdef FLB_SYSTEM_MACOS + ret = oauth2_mock_server_wait_ready(&server); + TEST_CHECK(ret == 0); +#endif + + ret = flb_oauth2_get_access_token(ctx, &token, FLB_FALSE); + TEST_CHECK(ret == 0); + ret = extract_form_value(server.latest_token_request, "client_secret", + value, sizeof(value)); + TEST_CHECK(ret == 0); + TEST_CHECK(strcmp(value, "secret-v1") == 0); + + ret = write_text_file(secret_path, "secret-v2\n"); + TEST_CHECK(ret == 0); + + /* Force a refresh; the reload happens inside oauth2_refresh_locked. */ + flb_oauth2_invalidate_token(ctx); + + ret = flb_oauth2_get_access_token(ctx, &token, FLB_FALSE); + TEST_CHECK(ret == 0); + ret = extract_form_value(server.latest_token_request, "client_secret", + value, sizeof(value)); + TEST_CHECK(ret == 0); + TEST_CHECK(strcmp(value, "secret-v2") == 0); + + flb_oauth2_destroy(ctx); + oauth2_mock_server_stop(&server); + unlink(secret_path); + flb_config_exit(config); +} + +void test_client_secret_file_only_validation(void) +{ + int ret; + char secret_path[256]; + struct flb_config *config; + struct flb_oauth2 *ctx; + struct oauth2_mock_server server; + + config = flb_config_init(); + TEST_CHECK(config != NULL); + + ret = test_secret_file_path(secret_path, sizeof(secret_path)); + TEST_CHECK(ret == 0); + + ret = write_text_file(secret_path, "only-file-secret\n"); + TEST_CHECK(ret == 0); + + ret = oauth2_mock_server_start(&server, 3600, 0); + TEST_CHECK(ret == 0); + + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, NULL); + TEST_CHECK(ctx != NULL); + + flb_oauth2_destroy(ctx); + oauth2_mock_server_stop(&server); + unlink(secret_path); + flb_config_exit(config); +} + +void test_client_secret_file_precedence(void) +{ + int ret; + char secret_path[256]; + char value[128]; + flb_sds_t token = NULL; + struct flb_config *config; + struct flb_oauth2 *ctx; + struct oauth2_mock_server server; + + config = flb_config_init(); + TEST_CHECK(config != NULL); + + ret = test_secret_file_path(secret_path, sizeof(secret_path)); + TEST_CHECK(ret == 0); + + ret = write_text_file(secret_path, "file-wins\n"); + TEST_CHECK(ret == 0); + + ret = oauth2_mock_server_start(&server, 3600, 0); + TEST_CHECK(ret == 0); + + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, + "inline-secret"); + TEST_CHECK(ctx != NULL); + +#ifdef FLB_SYSTEM_MACOS + ret = oauth2_mock_server_wait_ready(&server); + TEST_CHECK(ret == 0); +#endif + + ret = flb_oauth2_get_access_token(ctx, &token, FLB_FALSE); + TEST_CHECK(ret == 0); + ret = extract_form_value(server.latest_token_request, "client_secret", + value, sizeof(value)); + TEST_CHECK(ret == 0); + TEST_CHECK(strcmp(value, "file-wins") == 0); + + flb_oauth2_destroy(ctx); + oauth2_mock_server_stop(&server); + unlink(secret_path); + flb_config_exit(config); +} + +void test_client_secret_file_errors(void) +{ + int ret; + char secret_path[256]; + struct flb_config *config; + struct flb_oauth2 *ctx; + struct oauth2_mock_server server; + + config = flb_config_init(); + TEST_CHECK(config != NULL); + + ret = oauth2_mock_server_start(&server, 3600, 0); + TEST_CHECK(ret == 0); + + ret = test_secret_file_path(secret_path, sizeof(secret_path)); + TEST_CHECK(ret == 0); + + /* Nonexistent file -> creation fails. */ + unlink(secret_path); + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, NULL); + TEST_CHECK(ctx == NULL); + + /* Empty (newline-only) file -> creation fails. */ + ret = write_text_file(secret_path, "\n\n"); + TEST_CHECK(ret == 0); + ctx = create_oauth_ctx_secret_file(config, &server, secret_path, NULL); + TEST_CHECK(ctx == NULL); + + oauth2_mock_server_stop(&server); + unlink(secret_path); + flb_config_exit(config); +} + TEST_LIST = { {"parse_refreshes_token_transactionally", test_parse_refreshes_token_transactionally}, @@ -1264,5 +1523,12 @@ TEST_LIST = { {"legacy_create_manual_payload_flow", test_legacy_create_manual_payload_flow}, {"private_key_jwt_body", test_private_key_jwt_body}, {"private_key_jwt_x5t_header", test_private_key_jwt_x5t_header}, + {"client_secret_from_file", test_client_secret_from_file}, + {"client_secret_file_change_detection", + test_client_secret_file_change_detection}, + {"client_secret_file_only_validation", + test_client_secret_file_only_validation}, + {"client_secret_file_precedence", test_client_secret_file_precedence}, + {"client_secret_file_errors", test_client_secret_file_errors}, {0} };