From db2861cdad514c90cff59293d877307c83c53252 Mon Sep 17 00:00:00 2001 From: padams Date: Wed, 21 Oct 2026 19:31:07 +0000 Subject: [PATCH 1/2] Application passwords (OWA 2.x): credentials['app_password'] sends Authorization: Bearer An application password is used instead of the api_key/auth_key pair when set: sent as a Bearer token, no X-API-KEY, no signature. Read from the credentials config, OWA_APP_PASSWORD (env or constant), or app_password in ~/.owa/credentials. getCredentials() returns app_password beside the keys; the two tests that pinned the old two-key shape are updated. VERSION 1.2.0. --- README.md | 14 +++++++ src/OwaClient.php | 72 +++++++++++++++++++++++++--------- src/sdk.php | 2 +- tests/OwaClientTest.php | 87 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 154 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c5e77a4..ff1e64e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,20 @@ auth_key = your-owa-auth-key Requests are signed with the full URL, so `instance_url` must use the scheme, host and port that OWA itself sees. +### Application passwords (OWA 2.x) + +OWA 2.x issues application passwords: named, revocable credentials a user creates under My Preferences > Application Passwords. Pass one instead of the key pair: + +```php +$sites = $sdk->createSites( [ + 'credentials' => [ 'app_password' => '...' ], // 44 characters, shown once when created +] ); +``` + +It is sent as `Authorization: Bearer `, and the request is not signed, so no `auth_key` is needed. When an application password and the key pair are both set, the application password is used. The `OWA_APP_PASSWORD` environment variable or constant, or `app_password =` in `~/.owa/credentials`, work like the keys above. + +OWA refuses an application password sent over plain HTTP unless the install allows it, so use an `https` `instance_url`. A read-only password is enough for `listSites()`; `addSite()` needs a full one. + ## Configuration | Key | Default | | diff --git a/src/OwaClient.php b/src/OwaClient.php index df348ea..bce4861 100644 --- a/src/OwaClient.php +++ b/src/OwaClient.php @@ -66,32 +66,38 @@ public function setSetting( string $name, mixed $value ): void { } /** - * Credentials for the REST API. + * Credentials for the REST API: an application password, or the legacy + * API key and auth key. * - * api_key is the user's API key (their profile screen in OWA). auth_key is - * the install's OWA_AUTH_KEY constant from owa-config.php. + * app_password is an OWA application password (OWA 2.x: My Preferences > + * Application Passwords), sent as `Authorization: Bearer`. api_key is the + * user's API key (their profile screen in OWA) and auth_key the install's + * OWA_AUTH_KEY constant from owa-config.php; together they sign the + * request. When both kinds are set, the application password is used. * - * Looked up in order: the 'credentials' config array, the OWA_API_KEY / - * OWA_AUTH_KEY environment variables, constants of the same names, then - * the [defaults] or [default] section of ~/.owa/credentials. + * Looked up in order: the 'credentials' config array, the + * OWA_APP_PASSWORD / OWA_API_KEY / OWA_AUTH_KEY environment variables, + * constants of the same names, then the [defaults] or [default] section of + * ~/.owa/credentials. * - * @return array{api_key: string, auth_key: string} + * @return array{app_password: string, api_key: string, auth_key: string} */ public function getCredentials(): array { $credentials = $this->config['credentials']; - if ( is_array( $credentials ) && ! empty( $credentials['api_key'] ) && ! empty( $credentials['auth_key'] ) ) { + if ( is_array( $credentials ) && self::isComplete( $credentials ) ) { - return [ 'api_key' => (string) $credentials['api_key'], 'auth_key' => (string) $credentials['auth_key'] ]; + return self::normalizeCredentials( $credentials ); } $credentials = [ - 'api_key' => self::envOrConstant( 'OWA_API_KEY' ), - 'auth_key' => self::envOrConstant( 'OWA_AUTH_KEY' ), + 'app_password' => self::envOrConstant( 'OWA_APP_PASSWORD' ), + 'api_key' => self::envOrConstant( 'OWA_API_KEY' ), + 'auth_key' => self::envOrConstant( 'OWA_AUTH_KEY' ), ]; - if ( ! $credentials['api_key'] && ! $credentials['auth_key'] ) { + if ( ! $credentials['app_password'] && ! $credentials['api_key'] && ! $credentials['auth_key'] ) { $credentials = $this->readCredentialsFile() ?? $credentials; } @@ -101,6 +107,28 @@ public function getCredentials(): array { return $credentials; } + /** + * @param array $credentials + */ + private static function isComplete( array $credentials ): bool { + + return ! empty( $credentials['app_password'] ) + || ( ! empty( $credentials['api_key'] ) && ! empty( $credentials['auth_key'] ) ); + } + + /** + * @param array $credentials + * @return array{app_password: string, api_key: string, auth_key: string} + */ + private static function normalizeCredentials( array $credentials ): array { + + return [ + 'app_password' => (string) ( $credentials['app_password'] ?? '' ), + 'api_key' => (string) ( $credentials['api_key'] ?? '' ), + 'auth_key' => (string) ( $credentials['auth_key'] ?? '' ), + ]; + } + private static function envOrConstant( string $name ): string { $value = getenv( $name ); @@ -114,7 +142,7 @@ private static function envOrConstant( string $name ): string { } /** - * @return array{api_key: string, auth_key: string}|null + * @return array{app_password: string, api_key: string, auth_key: string}|null */ private function readCredentialsFile(): ?array { @@ -137,9 +165,9 @@ private function readCredentialsFile(): ?array { // [defaults] (what 1.0.x read), [default] (what its docs said), or bare keys $section = is_array( $ini ) ? ( $ini['defaults'] ?? $ini['default'] ?? $ini ) : null; - if ( is_array( $section ) && isset( $section['api_key'], $section['auth_key'] ) ) { + if ( is_array( $section ) && self::isComplete( $section ) ) { - return [ 'api_key' => (string) $section['api_key'], 'auth_key' => (string) $section['auth_key'] ]; + return self::normalizeCredentials( $section ); } return null; @@ -215,7 +243,7 @@ public function generateRequestSignature( string|array $url, array $credentials } /** - * Sends a signed REST request. + * Sends a REST request, authenticated as getCredentials() describes. * * @param array{http_method: string, uri: string, query?: array, form_params?: array} $params * @return mixed the envelope's 'data' member, or null on failure (see getLastErrors()) @@ -228,12 +256,20 @@ public function makeRequest( array $params ): mixed { $credentials = $this->getCredentials(); $headers = [ - 'X-API-KEY' => $credentials['api_key'], - 'X-SIGNATURE' => $this->generateRequestSignature( $url, $credentials ), 'User-Agent' => self::USER_AGENT, 'Accept' => 'application/json', ]; + if ( $credentials['app_password'] !== '' ) { + + $headers['Authorization'] = 'Bearer ' . $credentials['app_password']; + + } else { + + $headers['X-API-KEY'] = $credentials['api_key']; + $headers['X-SIGNATURE'] = $this->generateRequestSignature( $url, $credentials ); + } + $body = null; if ( ! empty( $params['form_params'] ) ) { diff --git a/src/sdk.php b/src/sdk.php index 7b296a1..abacaef 100644 --- a/src/sdk.php +++ b/src/sdk.php @@ -18,7 +18,7 @@ */ class sdk { - const VERSION = '1.1.1'; + const VERSION = '1.2.0'; private array $config; diff --git a/tests/OwaClientTest.php b/tests/OwaClientTest.php index 0258820..fa68474 100644 --- a/tests/OwaClientTest.php +++ b/tests/OwaClientTest.php @@ -13,6 +13,9 @@ class OwaClientTest extends TestCase { const API_KEY = '0123456789abcdef0123456789abcdef'; const AUTH_KEY = 'install-auth-key'; + // the shape OWA 2.x issues: 44 base62 characters + const APP_PASSWORD = 'AbCdEfGhIjKl0123456789abcdefghijABCDEFGHIJ01'; + private FakeTransport $transport; private string $tmp = ''; @@ -26,6 +29,7 @@ protected function tearDown(): void { putenv( 'OWA_API_KEY' ); putenv( 'OWA_AUTH_KEY' ); + putenv( 'OWA_APP_PASSWORD' ); if ( $this->tmp && is_dir( $this->tmp ) ) { @@ -157,7 +161,7 @@ public function testCredentialsFromEnvironment(): void { $client = new OwaClient( [ 'transport' => $this->transport ] ); // 1.0.x copied the api key into auth_key here - $this->assertSame( [ 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $client->getCredentials() ); + $this->assertSame( [ 'app_password' => '', 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $client->getCredentials() ); } /** @@ -184,7 +188,86 @@ public function testCredentialsFromFile( string $section ): void { putenv( $home === false ? 'HOME' : 'HOME=' . $home ); } - $this->assertSame( [ 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $creds ); + $this->assertSame( [ 'app_password' => '', 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $creds ); + } + + public function testAppPasswordIsSentAsBearerWithoutSigning(): void { + + $this->transport->queue( new Response( 200, json_encode( [ 'data' => [] ] ) ) ); + + $this->sites( [ 'credentials' => [ 'app_password' => self::APP_PASSWORD ] ] )->listSites(); + + $headers = $this->transport->last()['headers']; + + $this->assertSame( 'Bearer ' . self::APP_PASSWORD, $headers['Authorization'] ); + $this->assertArrayNotHasKey( 'X-API-KEY', $headers ); + $this->assertArrayNotHasKey( 'X-SIGNATURE', $headers ); + } + + public function testAppPasswordWinsOverTheLegacyKeys(): void { + + $this->transport->queue( new Response( 200, json_encode( [ 'data' => [] ] ) ) ); + + $this->sites( [ 'credentials' => [ 'app_password' => self::APP_PASSWORD, 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ] ] )->listSites(); + + $headers = $this->transport->last()['headers']; + + $this->assertSame( 'Bearer ' . self::APP_PASSWORD, $headers['Authorization'] ); + $this->assertArrayNotHasKey( 'X-API-KEY', $headers ); + } + + public function testAppPasswordFromEnvironment(): void { + + putenv( 'OWA_APP_PASSWORD=' . self::APP_PASSWORD ); + + $client = new OwaClient( [ 'transport' => $this->transport ] ); + + $this->assertSame( [ 'app_password' => self::APP_PASSWORD, 'api_key' => '', 'auth_key' => '' ], $client->getCredentials() ); + } + + public function testAppPasswordFromFile(): void { + + $this->tmp = sys_get_temp_dir() . '/owa-sdk-test-' . bin2hex( random_bytes( 4 ) ); + mkdir( $this->tmp . '/.owa', 0700, true ); + file_put_contents( $this->tmp . '/.owa/credentials', "[default]\napp_password = " . self::APP_PASSWORD . "\n" ); + + $home = getenv( 'HOME' ); + putenv( 'HOME=' . $this->tmp ); + + try { + + $creds = ( new OwaClient( [] ) )->getCredentials(); + + } finally { + + putenv( $home === false ? 'HOME' : 'HOME=' . $home ); + } + + $this->assertSame( [ 'app_password' => self::APP_PASSWORD, 'api_key' => '', 'auth_key' => '' ], $creds ); + } + + public function testFailureLogCarriesNoAppPassword(): void { + + $this->tmp = sys_get_temp_dir() . '/owa-sdk-test-' . bin2hex( random_bytes( 4 ) ); + mkdir( $this->tmp ); + $log = $this->tmp . '/error.log'; + $previous = ini_set( 'error_log', $log ); + + try { + + $this->transport->queue( new Response( 401 ) ); + $this->sites( [ 'credentials' => [ 'app_password' => self::APP_PASSWORD ] ] )->listSites(); + + } finally { + + ini_set( 'error_log', (string) $previous ); + } + + $written = (string) file_get_contents( $log ); + + $this->assertStringContainsString( 'failed: HTTP 401', $written ); + $this->assertStringNotContainsString( self::APP_PASSWORD, $written ); + $this->assertStringNotContainsString( 'Authorization', $written ); } public function testInstanceUrlGetsTrailingSlash(): void { From d94a2822235139bf214b6ad0c6deb71d994a6e65 Mon Sep 17 00:00:00 2001 From: padams Date: Wed, 21 Oct 2026 19:37:42 +0000 Subject: [PATCH 2/2] Refuse to send an application password to an http:// instance_url Over http the password travels in clear, and a default OWA install refuses it anyway. allow_insecure_app_password (default false) sends it for an install that has app_passwords_require_https off. The key pair is unaffected. --- README.md | 5 ++++- src/OwaClient.php | 11 +++++++++++ tests/OwaClientTest.php | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff1e64e..f14109b 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,9 @@ $sites = $sdk->createSites( [ It is sent as `Authorization: Bearer `, and the request is not signed, so no `auth_key` is needed. When an application password and the key pair are both set, the application password is used. The `OWA_APP_PASSWORD` environment variable or constant, or `app_password =` in `~/.owa/credentials`, work like the keys above. -OWA refuses an application password sent over plain HTTP unless the install allows it, so use an `https` `instance_url`. A read-only password is enough for `listSites()`; `addSite()` needs a full one. +The SDK does not send an application password to an `http://` `instance_url`; the request fails with `Application password not sent: instance_url is not https`. OWA refuses one over plain HTTP too, unless the install turns `app_passwords_require_https` off; for such an install, set `allow_insecure_app_password` to `true`. + +A read-only password is enough for `listSites()`; `addSite()` needs a full one. ## Configuration @@ -129,6 +131,7 @@ OWA refuses an application password sent over plain HTTP unless the install allo | `respect_do_not_track` | `true` | | | `timeout` / `connect_timeout` | `5` / `2` | seconds | | `transport` | curl or streams | an `OwaSdk\Http\Transport` | +| `allow_insecure_app_password` | `false` | send an application password to an `http://` `instance_url` | | `debug` / `debug_log` | `false` / PHP's error log | debug messages and where they go | ## Upgrading from 1.0.x diff --git a/src/OwaClient.php b/src/OwaClient.php index bce4861..11033f1 100644 --- a/src/OwaClient.php +++ b/src/OwaClient.php @@ -40,6 +40,8 @@ public function __construct( array $config = [] ) { 'debug' => false, 'timeout' => 5, 'connect_timeout' => 2, + // send an application password to an http:// instance_url + 'allow_insecure_app_password' => false, // an OwaSdk\Http\Transport; null picks curl, or streams without ext-curl 'transport' => null, ]; @@ -262,6 +264,15 @@ public function makeRequest( array $params ): mixed { if ( $credentials['app_password'] !== '' ) { + // refused before sending: over http the password would travel in clear + if ( strtolower( (string) parse_url( $url, PHP_URL_SCHEME ) ) !== 'https' && ! $this->getSetting( 'allow_insecure_app_password' ) ) { + + $this->last_errors = [ 'Application password not sent: instance_url is not https' ]; + error_log( sprintf( 'OWA SDK: %s %s failed: %s', $params['http_method'], strtok( $url, '?' ), $this->last_errors[0] ) ); + + return null; + } + $headers['Authorization'] = 'Bearer ' . $credentials['app_password']; } else { diff --git a/tests/OwaClientTest.php b/tests/OwaClientTest.php index fa68474..a6e1dcc 100644 --- a/tests/OwaClientTest.php +++ b/tests/OwaClientTest.php @@ -204,6 +204,41 @@ public function testAppPasswordIsSentAsBearerWithoutSigning(): void { $this->assertArrayNotHasKey( 'X-SIGNATURE', $headers ); } + public function testAppPasswordIsNotSentOverHttp(): void { + + $client = $this->sites( [ + 'instance_url' => 'http://owa.example.com/owa', + 'credentials' => [ 'app_password' => self::APP_PASSWORD, 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], + ] ); + + $this->assertNull( $client->listSites() ); + $this->assertSame( [], $this->transport->requests ); + $this->assertSame( [ 'Application password not sent: instance_url is not https' ], $client->getLastErrors() ); + } + + public function testAllowInsecureAppPasswordSendsOverHttp(): void { + + $this->transport->queue( new Response( 200, json_encode( [ 'data' => [] ] ) ) ); + + $client = $this->sites( [ + 'instance_url' => 'http://owa.example.com/owa', + 'credentials' => [ 'app_password' => self::APP_PASSWORD ], + 'allow_insecure_app_password' => true, + ] ); + + $this->assertSame( [], $client->listSites() ); + $this->assertSame( 'Bearer ' . self::APP_PASSWORD, $this->transport->last()['headers']['Authorization'] ); + } + + public function testKeyPairIsStillSentOverHttp(): void { + + $this->transport->queue( new Response( 200, json_encode( [ 'data' => [] ] ) ) ); + + $this->sites( [ 'instance_url' => 'http://owa.example.com/owa' ] )->listSites(); + + $this->assertArrayHasKey( 'X-SIGNATURE', $this->transport->last()['headers'] ); + } + public function testAppPasswordWinsOverTheLegacyKeys(): void { $this->transport->queue( new Response( 200, json_encode( [ 'data' => [] ] ) ) );