Dynamic Bearer Token Authentication Handler

Add support for dynamic bearer token authentication to GC API Alchemist.

Many APIs use token-based authentication where you authenticate once to receive a token, then use that token for subsequent requests until it expires.

Instructions

See “Where do I put snippets?” in our documentation for installation instructions.

Code

Filename: gcapi-dynamic-bearer-token-authentication.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
<?php
/**
 * Gravity Wiz // API Alchemist // Dynamic Bearer Token Authentication Handler
 *
 * Add support for dynamic bearer token authentication to GC API Alchemist.
 *
 * Many APIs use token-based authentication where you authenticate once to receive
 * a token, then use that token for subsequent requests until it expires.
 */

use GC_API_Alchemist\Authentication\Auth_Handler;
use GC_API_Alchemist\Connection_Profiles\Connection_Profile;

function gcapi_register_dynamic_bearer_handler() {
	if ( ! class_exists( 'GC_API_Alchemist\\Authentication\\Auth_Handler' ) ) {
		return;
	}

	if ( class_exists( 'GCAPI_Dynamic_Bearer_Auth_Handler' ) ) {
		return;
	}

	class GCAPI_Dynamic_Bearer_Auth_Handler extends Auth_Handler {

		/**
		 * Get the unique authentication type identifier
		 *
		 * @return string
		 */
		public function get_type(): string {
			return 'dynamic_bearer';
		}

		/**
		 * Get the human-readable label for this auth type
		 *
		 * @return string
		 */
		public function get_label(): string {
			return 'Dynamic Bearer Token';
		}

		/**
		 * Get configuration fields for this auth type
		 *
		 * @return array
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations, PHPCompatibility.ParameterTypeHints.NullableTypeNotSupported
		public function get_config_fields(): ?array {
			return array(
				'token_endpoint'    => array(
					'type'        => 'text',
					'label'       => 'Token Endpoint URL',
					'description' => 'The API endpoint that generates authentication tokens',
					'required'    => true,
				),
				'username'          => array(
					'type'        => 'text',
					'label'       => 'Credential Value',
					'description' => 'Your API token, username, client ID, or key (field name configured below)',
					'required'    => true,
				),
				'password'          => array(
					'type'        => 'password',
					'label'       => 'Credential Secret',
					'description' => 'Your API secret, password, or client secret (field name configured below)',
					'required'    => true,
				),
				'username_field'    => array(
					'type'        => 'text',
					'label'       => 'Token Field Name',
					'description' => 'What the API calls this value in requests. Examples: username, api_token, token, client_id, api_key, consumer_key, app_id',
					'default'     => 'username',
				),
				'password_field'    => array(
					'type'        => 'text',
					'label'       => 'Secret Field Name',
					'description' => 'What the API calls this value in requests. Examples: password, api_secret, secret, client_secret, consumer_secret, app_secret',
					'default'     => 'password',
				),
				'token_body_format' => array(
					'type'        => 'select',
					'label'       => 'Token Request Body Format',
					'description' => 'How to send credentials to the token endpoint',
					'default'     => 'json',
					'options'     => array(
						'json' => 'JSON (application/json)',
						'form' => 'Form (application/x-www-form-urlencoded)',
					),
				),
				'send_basic_auth'   => array(
					'type'        => 'select',
					'label'       => 'Send Basic Authorization Header',
					'description' => 'Include Authorization: Basic header with the credentials',
					'default'     => 'yes',
					'options'     => array(
						'yes' => 'Yes',
						'no'  => 'No',
					),
				),
				'token_ttl'         => array(
					'type'        => 'number',
					'label'       => 'Token Lifetime (seconds)',
					'description' => 'How long tokens are valid (default: 3600).',
					'default'     => HOUR_IN_SECONDS,
				),
			);
		}

		/**
		 * Validate authentication configuration
		 *
		 * @param array $config Configuration values
		 * @return bool
		 */
		public function validate_config( array $config ): bool {
			return ! empty( $config['token_endpoint'] )
				&& ! empty( $config['username'] )
				&& ! empty( $config['password'] );
		}

		/**
		 * Apply authentication to request
		 *
		 * Adds the bearer token to the Authorization header.
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @param array              $guzzle_options Guzzle request options (passed by reference)
		 * @return void
		 * @throws Exception If authentication fails
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations
		public function apply_authentication( Connection_Profile $profile, array &$guzzle_options ): void {
			$token = $this->get_stored_token( $profile );

			if ( empty( $token ) ) {
				throw new Exception( 'Failed to obtain bearer token' );
			}

			// Add token to Authorization header
			$guzzle_options['headers']['Authorization'] = 'Bearer ' . $token;
		}

		/**
		 * Check if this handler supports token refresh
		 *
		 * @return bool
		 */
		protected function supports_token_refresh(): bool {
			return true;
		}

		/**
		 * Get stored authentication token
		 *
		 * Returns cached token if available and not expired, otherwise authenticates.
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @return string|null
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations, PHPCompatibility.ParameterTypeHints.NullableTypeNotSupported
		protected function get_stored_token( Connection_Profile $profile ): ?string {
			// Check for cached token
			$cached_token = $profile->get_auth_config_value( 'session_token' );
			$expires_at   = $profile->get_auth_config_value( 'expires_at' );

			if ( ! empty( $cached_token ) && ! empty( $expires_at ) && time() < (int) $expires_at ) {
				return $cached_token;
			}

			// Get new token
			return $this->authenticate( $profile );
		}

		/**
		 * Store authentication token with expiration
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @param string             $token The token to store
		 * @param int|null           $expires_at Unix timestamp when token expires
		 * @return void
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations, PHPCompatibility.ParameterTypeHints.NullableTypeNotSupported
		protected function set_stored_token( Connection_Profile $profile, string $token, ?int $expires_at ): void {
			$profile->set_auth_config_value( 'session_token', $token );
			$profile->set_auth_config_value( 'expires_at', $expires_at !== null ? (int) $expires_at : null );
		}

		/**
		 * Get token expiration timestamp
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @return int|null
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations, PHPCompatibility.ParameterTypeHints.NullableTypeNotSupported
		protected function get_token_expiration( Connection_Profile $profile ): ?int {
			$expires_at = $profile->get_auth_config_value( 'expires_at' );
			if ( empty( $expires_at ) ) {
				return null;
			}

			return (int) $expires_at;
		}

		/**
		 * Clear stored authentication token
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @return void
		 */
		// phpcs:ignore PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations
		protected function clear_stored_token( Connection_Profile $profile ): void {
			$profile->set_auth_config_value( 'session_token', null );
			$profile->set_auth_config_value( 'expires_at', null );
		}

		/**
		 * Get the configured token lifetime in seconds.
		 *
		 * @param Connection_Profile $profile The connection profile.
		 * @return int
		 */
		protected function get_token_ttl( Connection_Profile $profile ): int {
			$token_ttl = (int) $profile->get_auth_config_value( 'token_ttl', HOUR_IN_SECONDS );
			if ( $token_ttl <= 0 ) {
				$token_ttl = HOUR_IN_SECONDS;
			}

			return $token_ttl;
		}

		/**
		 * Lifecycle hook: Called to refresh authentication tokens
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @return bool
		 */
		public function on_token_refresh( Connection_Profile $profile ): bool {
			try {
				$token = $this->authenticate( $profile );

				return ! empty( $token );
			} catch ( Exception $e ) {
				gc_api_alchemist()->log_error( 'Dynamic bearer token refresh failed: ' . $e->getMessage() );
				return false;
			}
		}

		/**
		 * Authenticate with API and get a bearer token
		 *
		 * @param Connection_Profile $profile The connection profile
		 * @return string|null The bearer token or null on failure
		 * @throws Exception If authentication fails
		 */
		protected function authenticate( Connection_Profile $profile ) {
			$token_endpoint    = $profile->get_auth_config_value( 'token_endpoint' );
			$username          = $profile->get_auth_config_value( 'username' );
			$password          = $profile->get_auth_config_value( 'password' );
			$username_field    = $profile->get_auth_config_value( 'username_field', 'username' );
			$password_field    = $profile->get_auth_config_value( 'password_field', 'password' );
			$token_body_format = $profile->get_auth_config_value( 'token_body_format', 'json' );
			$send_basic_auth   = $profile->get_auth_config_value( 'send_basic_auth', 'yes' );

			if ( empty( $token_endpoint ) || empty( $username ) || empty( $password ) ) {
				throw new Exception( 'Token endpoint, username, and password are required' );
			}

			$token_endpoint = $this->resolve_token_endpoint_url( $profile, $token_endpoint );

			if ( empty( $username_field ) ) {
				$username_field = 'username';
			}

			if ( empty( $password_field ) ) {
				$password_field = 'password';
			}

			$payload = array(
				$username_field => $username,
				$password_field => $password,
			);

			$headers = array(
				'Accept' => 'application/json',
			);

			if ( $send_basic_auth !== 'no' ) {
				$headers['Authorization'] = 'Basic ' . base64_encode( $username . ':' . $password );
			}

			if ( $token_body_format === 'form' ) {
				$headers['Content-Type'] = 'application/x-www-form-urlencoded';
				$body                    = $payload;
			} else {
				$headers['Content-Type'] = 'application/json';
				$body                    = wp_json_encode( $payload );
				if ( $body === false ) {
					throw new Exception( 'Failed to encode token request payload: ' . json_last_error_msg() );
				}
			}

			// Make authentication request
			$response = wp_remote_post( $token_endpoint, array(
				'headers' => $headers,
				'body'    => $body,
				'timeout' => 30,
			) );

			if ( is_wp_error( $response ) ) {
				throw new Exception( 'Token request failed: ' . $response->get_error_message() );
			}

			$status_code = wp_remote_retrieve_response_code( $response );
			$body        = wp_remote_retrieve_body( $response );
			$data        = json_decode( $body, true );

			// Check for successful authentication (HTTP 2xx)
			if ( $status_code < 200 || $status_code >= 300 ) {
				$error_message = '';
				if ( is_array( $data ) ) {
					$error_message = $data['messages'][0]['message']
						?? $data['message']
						?? $data['error']
						?? $data['detail']
						?? '';
				}

				if ( empty( $error_message ) ) {
					$error_message = trim( wp_strip_all_tags( (string) $body ) );
				}

				if ( empty( $error_message ) ) {
					$error_message = 'Unknown error';
				}

				throw new Exception( sprintf( 'Authentication failed (HTTP %d): %s', $status_code, $error_message ) );
			}

			// Extract token from response (JSON or raw string)
			if ( is_array( $data ) ) {
				$token = $data['response']['token'] ?? $data['access_token'] ?? $data['token'] ?? null;
			} elseif ( is_string( $data ) && $data !== '' ) {
				$token = $data;
			} else {
				$token = trim( (string) $body );
				$token = trim( $token, "\" \t\n\r\0\x0B" );
			}

			if ( empty( $token ) ) {
				throw new Exception( 'No token returned from authentication' );
			}

			// Store token with expiration
			$this->set_stored_token( $profile, $token, time() + $this->get_token_ttl( $profile ) );

			return $token;
		}

		/**
		 * Resolve the token endpoint URL against the profile base URL when relative.
		 *
		 * @param Connection_Profile $profile The connection profile.
		 * @param string             $token_endpoint The configured token endpoint.
		 * @return string
		 */
		protected function resolve_token_endpoint_url( Connection_Profile $profile, string $token_endpoint ): string {
			$token_endpoint = trim( $token_endpoint );
			if ( empty( $token_endpoint ) ) {
				return $token_endpoint;
			}

			$parsed = wp_parse_url( $token_endpoint );
			if ( ! empty( $parsed['scheme'] ) && ! empty( $parsed['host'] ) ) {
				return $token_endpoint;
			}

			$base_url = $profile->get_base_url();
			if ( empty( $base_url ) ) {
				throw new Exception( 'Token endpoint must be a full URL when no base URL is configured.' );
			}

			return $profile->build_url( $token_endpoint );
		}
	}

	if ( ! function_exists( 'gcapi_register_auth_handler' ) ) {
		return;
	}

	gcapi_register_auth_handler( new GCAPI_Dynamic_Bearer_Auth_Handler() );
}

if ( did_action( 'plugins_loaded' ) ) {
	gcapi_register_dynamic_bearer_handler();
} else {
	add_action( 'plugins_loaded', 'gcapi_register_dynamic_bearer_handler' );
}

Leave a Reply

Your email address will not be published. Required fields are marked *

  • Trouble installing this snippet? See our troubleshooting tips.
  • Need to include code? Create a gist and link to it in your comment.
  • Reporting a bug? Provide a URL where this issue can be recreated.

By commenting, I understand that I may receive emails related to Gravity Wiz and can unsubscribe at any time.