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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ 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 <password>`, 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.

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

| Key | Default | |
Expand All @@ -115,6 +131,7 @@ Requests are signed with the full URL, so `instance_url` must use the scheme, ho
| `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
Expand Down
83 changes: 65 additions & 18 deletions src/OwaClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Expand All @@ -66,32 +68,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;
}
Expand All @@ -101,6 +109,28 @@ public function getCredentials(): array {
return $credentials;
}

/**
* @param array<string, mixed> $credentials
*/
private static function isComplete( array $credentials ): bool {

return ! empty( $credentials['app_password'] )
|| ( ! empty( $credentials['api_key'] ) && ! empty( $credentials['auth_key'] ) );
}

/**
* @param array<string, mixed> $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 );
Expand All @@ -114,7 +144,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 {

Expand All @@ -137,9 +167,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;
Expand Down Expand Up @@ -215,7 +245,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<string, mixed>, form_params?: array<string, mixed>} $params
* @return mixed the envelope's 'data' member, or null on failure (see getLastErrors())
Expand All @@ -228,12 +258,29 @@ 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'] !== '' ) {

// 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 {

$headers['X-API-KEY'] = $credentials['api_key'];
$headers['X-SIGNATURE'] = $this->generateRequestSignature( $url, $credentials );
}

$body = null;

if ( ! empty( $params['form_params'] ) ) {
Expand Down
2 changes: 1 addition & 1 deletion src/sdk.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
class sdk {

const VERSION = '1.1.1';
const VERSION = '1.2.0';

private array $config;

Expand Down
122 changes: 120 additions & 2 deletions tests/OwaClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand All @@ -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 ) ) {

Expand Down Expand Up @@ -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() );
}

/**
Expand All @@ -184,7 +188,121 @@ 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 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' => [] ] ) ) );

$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 {
Expand Down
Loading