Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation #1

Merged
merged 19 commits into from
Apr 1, 2024
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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
- 'high'
- 'low'
php:
- '7.4'
- '8.0'
- '8.1'
- '8.2'
- '8.3'
- '8.4-dev'

steps:
- name: Check out code
Expand Down
116 changes: 101 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,105 @@
# php-library-template
Repository template for PHP libraries. Sets up composer, CI with Github Actions, and more.
# SnapAuth PHP SDK

## Git
- Configures `.gitignore` for common excludes in a PHP library
This is the official PHP SDK for [SnapAuth](https://www.snapauth.app).

## Composer
- Placeholders for library name, description, and PSR-4 autoloading
- Scripts for testing
- Requires current version of PHP
- Includes testing tools (configured) as dev dependencies
## Documentation

## Testing and CI
CI is configured using Github Actions.
Full API and usage docs are available [at the official site](https://docs.snapauth.app/server.html#introduction).

- PHPUnit `^9.3` with default configuration (`src`/`tests`).
- The tests workflow uses a build matrix to test against multiple versions of PHP, and with high and low Composer dependencies installed
- PHPStan with strict ruleset, max level, and the PHPUnit extension
- PHP Code Sniffer configured with PSR-12
## Installation

```bash
composer require snapauth/sdk
```

## Setup

Get your _secret_ key from the [dashboard](https://dashboard.snapauth.app).
Provide it to the `SnapAuth\Client` class:

```php
use SnapAuth\Client;

$yourSecret = getenv('SNAPAUTH_SECRET_KEY');
$snapAuth = new Client(secretKey: $yourSecret);
```

> [!TIP]
> Secret keys are specific to an environment and domain.
> We HIGHLY RECOMMEND using environment variables or another external storage mechanism.
> Avoid committing them to version control, as this can more easily lead to compromise.

## Usage

### Registration

Once you obtain a registration token from your frontend, use the `Client` to complete the process and attach it to the user:

```php
$token = 'value_from_frontend'; // $_POST['snapauth_token'] or similar
$userInfo = [
'id' => 'your_user_id',
'handle' => 'your_user_handle',
];
$snapAuth->attachRegistration($token, $userInfo);
```

<!--
Registration returns an `AttachResponse` object, which contains a credential identifier.
You may store this information at your end, but it's not necessary in most cases.
-->

This activates the passkey and associates it with the user.
`$userInfo` will be provided back to you during authentication, so you know who is signing in.

`id` should be some sort of _stable_ identifer, like a database primary key.

`handle` can be anything you want, or omitted entirely.
It's a convenience during _client_ authentication so you don't need to look up the user id again.
This would commonly be the value a user provides to sign in, such as a username or email.

Both must be strings, and can be up to 255 characters long.
Lookups during authentication are **case-insensitive**.

> [!TIP]
> We strongly ENCOURAGE you to obfuscate any possibly sensitive information, such as email addresses.
> You can accomplish this by hashing the value.
> Be aware that to use the handle during authentication, you will want to replicate the obfuscation procedure on your frontend.

### Authentication

Like registration, you will need to obtain a token from your frontend provided by the client SDK.

Use the `verifyAuthToken` method to get information about the authentication process, in the form of an `AuthResponse` object.
This object contains the previously-registered User `id` and `handle`.

```php
$token = 'value_from_frontend'; // $_POST['snapauth_token'] or similar
$authInfo = $snapAuth->verifyAuthToken($token);

// Specific to your application:
$authenticatedUserId = $authInfo->user->id;

// Laravel:
use Illuminate\Support\Facades\Auth;
Auth::loginUsingId($authenticatedUserId);
```

## Error Handling

The SnapAuth SDK is written in a fail-secure manner, and will throw an exception if you're not on the successful path.
This helps ensure that your integration is easy and reliable.

You may choose to locally wrap API calls in a `try/catch` block, or let a general application-wide error handler deal with any exceptions.

All SnapAuth exceptions are an `instanceof \SnapAuth\ApiError`.

## Compatibility

We follow semantic versioning, and limit backwards-incompatible changes to major versions (the X in X.Y.Z) only.

The SnapAuth SDK is maintained for all versions of PHP with [current security support](https://www.php.net/supported-versions.php).
Since Composer will platform-detect your currently-installed version of PHP, dropping support for older versions is _not_ considered a backwards compatibility break (but you may be unable to install newer versions until updating to a supported version of PHP).

Anything marked as `@internal` or any `protected` or `private` method is not considered in scope for backwards-compatibility guarantees.
Similarly, all methods should be treated as ones that may throw an exception, and as such new types of exceptions are not considered a BC break either.
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
}
},
"require": {
"php": "^7.4 || ^8.0",
"ext-curl": "*"
"php": "^8.1",
"ext-curl": "*",
"composer-runtime-api": "^2.2"
},
"require-dev": {
"maglnet/composer-require-checker": "^2.0 || ^3.0 || ^4.0",
Expand Down
Empty file removed src/.gitkeep
Empty file.
11 changes: 11 additions & 0 deletions src/ApiError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

use Exception;

class ApiError extends Exception
{
}
14 changes: 14 additions & 0 deletions src/AttachResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

class AttachResponse
{
// @phpstan-ignore-next-line
public function __construct(array $data)

Check warning on line 10 in src/AttachResponse.php

View check run for this annotation

Codecov / codecov/patch

src/AttachResponse.php#L10

Added line #L10 was not covered by tests
{
// Intentionally empty for now - response format uncertain
}

Check warning on line 13 in src/AttachResponse.php

View check run for this annotation

Codecov / codecov/patch

src/AttachResponse.php#L13

Added line #L13 was not covered by tests
}
16 changes: 16 additions & 0 deletions src/AuthResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

class AuthResponse
{
public readonly User $user;

// @phpstan-ignore-next-line
public function __construct(array $data)

Check warning on line 12 in src/AuthResponse.php

View check run for this annotation

Codecov / codecov/patch

src/AuthResponse.php#L12

Added line #L12 was not covered by tests
{
$this->user = new User($data['user']);
}

Check warning on line 15 in src/AuthResponse.php

View check run for this annotation

Codecov / codecov/patch

src/AuthResponse.php#L14-L15

Added lines #L14 - L15 were not covered by tests
}
166 changes: 166 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

use Composer\InstalledVersions;
use JsonException;
use SensitiveParameter;

use function assert;
use function curl_close;
use function curl_errno;
use function curl_exec;
use function curl_getinfo;
use function curl_init;
use function curl_setopt_array;
use function curl_version;
use function is_array;
use function is_string;
use function json_decode;
use function json_encode;
use function sprintf;
use function strlen;

use const CURLE_OK;
use const CURLINFO_RESPONSE_CODE;
use const CURLOPT_HTTPHEADER;
use const CURLOPT_POST;
use const CURLOPT_POSTFIELDS;
use const CURLOPT_RETURNTRANSFER;
use const CURLOPT_URL;
use const JSON_THROW_ON_ERROR;

/**
* SDK Prototype. This makes no attempt to short-circuit the network for
* internal use, forcing a completely dogfooded experience.
*
* TODO: make testable, presumably via PSR-18
* TODO: make an interface so the entire client can be mocked by developers
*/
class Client
{
private const DEFAULT_API_HOST = 'https://api.snapauth.app';

public function __construct(
#[SensitiveParameter] private string $secretKey,
private string $apiHost = self::DEFAULT_API_HOST,
) {
if (!str_starts_with($secretKey, 'secret_')) {
throw new ApiError(
'Invalid secret key. Please verify you copied the full value from the SnapAuth dashboard.',
);
}
}

/**
* return array{
* user: array{
* id: string,
* handle: string,
* },
* }
*/
public function verifyAuthToken(string $authToken): AuthResponse

Check warning on line 65 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L65

Added line #L65 was not covered by tests
{
return $this->makeApiCall(
route: '/auth/verify',
data: [
'token' => $authToken,
],
type: AuthResponse::class,
);

Check warning on line 73 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L67-L73

Added lines #L67 - L73 were not covered by tests
}

/**
* @param array{
* handle: string,
* id?: string,
* } $user
*/
public function attachRegistration(string $regToken, array $user): AttachResponse

Check warning on line 82 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L82

Added line #L82 was not covered by tests
{
return $this->makeApiCall(
route: '/registration/attach',
data: [
'token' => $regToken,
'user' => $user,
],
type: AttachResponse::class
);

Check warning on line 91 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L84-L91

Added lines #L84 - L91 were not covered by tests
}

/**
* @template T of object
* @param mixed[] $data
* @param class-string<T> $type
* @return T
*/
private function makeApiCall(string $route, array $data, string $type): object

Check warning on line 100 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L100

Added line #L100 was not covered by tests
{
// TODO: PSR-xx
$json = json_encode($data, JSON_THROW_ON_ERROR);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => sprintf('%s%s', $this->apiHost, $route),
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $json,

Check warning on line 108 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L103-L108

Added lines #L103 - L108 were not covered by tests
// CURLOPT_VERBOSE => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Basic ' . base64_encode(':' . $this->secretKey),
'Accept: application/json',
'Content-Type: application/json',
'Content-Length: ' . strlen($json),
sprintf(
'User-Agent: php-sdk/%s curl/%s php/%s',
InstalledVersions::getVersion('snapauth/sdk'),
curl_version()['version'] ?? 'unknown',
PHP_VERSION,
),
sprintf('X-SDK: php/%s', InstalledVersions::getVersion('snapauth/sdk')),
],
]);

Check warning on line 124 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L110-L124

Added lines #L110 - L124 were not covered by tests

try {
$response = curl_exec($ch);
$errno = curl_errno($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

Check warning on line 129 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L127-L129

Added lines #L127 - L129 were not covered by tests

if ($response === false || $errno !== CURLE_OK) {
$this->error();

Check warning on line 132 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L131-L132

Added lines #L131 - L132 were not covered by tests
}

if ($code >= 300) {
$this->error();

Check warning on line 136 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L135-L136

Added lines #L135 - L136 were not covered by tests
}
// Handle non-200s, non-JSON (severe upstream error)
assert(is_string($response));
$decoded = json_decode($response, true, flags: JSON_THROW_ON_ERROR);
assert(is_array($decoded));
return new $type($decoded['result']);
} catch (JsonException) {
$this->error();
} finally {
curl_close($ch);

Check warning on line 146 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L139-L146

Added lines #L139 - L146 were not covered by tests
}
}

Check warning on line 148 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L148

Added line #L148 was not covered by tests

/**
* TODO: specific error info!
*/
private function error(): never

Check warning on line 153 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L153

Added line #L153 was not covered by tests
{
throw new ApiError();

Check warning on line 155 in src/Client.php

View check run for this annotation

Codecov / codecov/patch

src/Client.php#L155

Added line #L155 was not covered by tests
// TODO: also make this more specific
}

public function __debugInfo(): array
{
return [
'apiHost' => $this->apiHost,
'secretKey' => substr($this->secretKey, 0, 9) . '***' . substr($this->secretKey, -2),
];
}
}
23 changes: 0 additions & 23 deletions src/Example.php

This file was deleted.

18 changes: 18 additions & 0 deletions src/User.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace SnapAuth;

class User
{
public readonly string $id;
public readonly string $handle;

// @phpstan-ignore-next-line
public function __construct(array $data)

Check warning on line 13 in src/User.php

View check run for this annotation

Codecov / codecov/patch

src/User.php#L13

Added line #L13 was not covered by tests
{
$this->id = $data['id'];
$this->handle = $data['handle'];
}

Check warning on line 17 in src/User.php

View check run for this annotation

Codecov / codecov/patch

src/User.php#L15-L17

Added lines #L15 - L17 were not covered by tests
}
Empty file removed tests/.gitkeep
Empty file.
Loading
Loading