Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .phpunit.cache/test-results

Large diffs are not rendered by default.

386 changes: 292 additions & 94 deletions README.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions config/bexio.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

return [
'auth' => [
'use_oauth2' => env('BEXIO_USE_OAUTH2', false),
'token' => env('BEXIO_API_TOKEN'),
'client_id' => env('BEXIO_OAUTH2_CLIENT_ID'),
'client_secret' => env('BEXIO_OAUTH2_CLIENT_SECRET'),
'oauth_email' => env('BEXIO_OAUTH2_EMAIL'),
'scopes' => [],
],

/* 'auth' => [
'token' => env('BEXIO_API_TOKEN'),

'oauth2' => [
'client_id' => env('BEXIO_OAUTH2_CLIENT_ID'),
'client_secret' => env('BEXIO_OAUTH2_CLIENT_SECRET'),
'email' => env('BEXIO_OAUTH2_EMAIL'),
'scopes' => [],
],*/

'route_prefix' => 'bexio',
];
28 changes: 28 additions & 0 deletions resources/views/oauth-result.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>{{ $title ?? 'Bexio OAuth2 Result' }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">

</head>

<body>

<div class="bexio-oauth-card">
<h2 class="bexio-oauth-title">{{ $title }}</h2>
<p class="bexio-oauth-message">{{ $message }}</p>
@if (isset($actions) && is_array($actions))
<div class="bexio-oauth-actions">
@foreach ($actions as $btn)
<a href="{{ $btn['url'] }}" class="bexio-oauth-btn {{ $btn['class'] ?? '' }}">{{ $btn['label'] }}</a>
@endforeach
</div>
@elseif(isset($action))
<a href="{{ $action['url'] }}" class="bexio-oauth-btn">{{ $action['label'] }}</a>
@endif
</div>
</body>

</html>
15 changes: 15 additions & 0 deletions routes/bexio.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use CodebarAg\Bexio\Http\Controllers\BexioOAuthController;
use Illuminate\Support\Facades\Route;

/**
* Bexio OAuth routes.
*
* The default prefix is 'bexio'. It can be customized via 'route_prefix' in config/bexio.php.
* If you change route names, update the connector accordingly.
*/
Route::middleware(['web'])->prefix(config('bexio.route_prefix', 'bexio'))->group(function () {
Route::get('/oauth/redirect', [BexioOAuthController::class, 'redirect'])->name('bexio.oauth.redirect');
Route::get('/oauth/callback', [BexioOAuthController::class, 'callback'])->name('bexio.oauth.callback');
});
55 changes: 54 additions & 1 deletion src/BexioConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@

namespace CodebarAg\Bexio;

use CodebarAg\Bexio\Services\BexioOAuthService;
use CodebarAg\Bexio\Support\BexioOAuthTokenStore;
use Saloon\Contracts\Authenticator;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\Auth\TokenAuthenticator;
use Saloon\Http\Connector;
use Saloon\Http\OAuth2\GetRefreshTokenRequest;
use Saloon\Http\PendingRequest;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;

class BexioConnector extends Connector
{
use AlwaysThrowOnErrors, AuthorizationCodeGrant;

public function __construct(
protected readonly ?string $token = null,
) {}
protected ?BexioOAuthTokenStore $tokenStore = null,
protected ?BexioOAuthService $bexioOAuthService = null,
) {
$this->tokenStore ??= app(BexioOAuthTokenStore::class);
$this->bexioOAuthService ??= app(BexioOAuthService::class);
}

public function resolveBaseUrl(): string
{
Expand All @@ -24,8 +38,47 @@ protected function defaultHeaders(): array
];
}

/**
* Saloon boot method: runs before every request.
* Handles token refresh for OAuth2 and sets PAT for legacy tokens.
*/
public function boot(PendingRequest $pendingRequest): void
{
$pendingRequest->middleware()->onRequest(function (PendingRequest $pendingRequest) {
if (config('bexio.auth.use_oauth2')) {
// Prevent recursion: do not refresh while already refreshing
if ($pendingRequest->getRequest() instanceof GetRefreshTokenRequest) {
return;
}
$authenticator = $this->tokenStore->get();
if ($authenticator && $authenticator->hasExpired()) {
$authenticator = $this->bexioOAuthService->refreshAuthenticator($this->tokenStore, $this);
$pendingRequest->authenticate($authenticator);
}
}
});
}

protected function defaultAuth(): ?Authenticator
{
if (config('bexio.auth.use_oauth2')) {
return $this->tokenStore->get();
}

return new TokenAuthenticator($this->token ?? config('bexio.auth.token'), 'Bearer');
}

/**
* Saloon OAuth2 config for Bexio
*/
protected function defaultOauthConfig(): OAuthConfig
{
return OAuthConfig::make()
->setClientId(config('bexio.auth.client_id'))
->setClientSecret(config('bexio.auth.client_secret'))
->setDefaultScopes(['openid', 'offline_access', 'email'])
->setRedirectUri(route('bexio.oauth.callback'))
->setAuthorizeEndpoint('https://auth.bexio.com/realms/bexio/protocol/openid-connect/auth')
->setTokenEndpoint('https://auth.bexio.com/realms/bexio/protocol/openid-connect/token');
}
}
11 changes: 10 additions & 1 deletion src/BexioServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ public function configurePackage(Package $package): void
{
$package
->name('laravel-bexio')
->hasConfigFile('bexio');
->hasConfigFile('bexio')
->hasRoute('bexio')
->hasViews('bexio');
}

public function bootingPackage()
{
parent::bootingPackage(); // TODO: Change the autogenerated stub

// Publish controller
if (function_exists('app_path')) {
$this->publishes([
__DIR__.'/Http/Controllers/BexioOAuthController.php' => app_path('Http/Controllers/BexioOAuthController.php'),
], 'bexio-controller');
}

// Issue Temporary Fix:
// https://github.com/spatie/laravel-data/pull/699#issuecomment-1995546874
// https://github.com/spatie/laravel-data/issues/731
Expand Down
54 changes: 54 additions & 0 deletions src/Dto/OpenID/UserinfoDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace CodebarAg\Bexio\Dto\OpenID;

use Exception;
use Illuminate\Support\Arr;
use Saloon\Http\Response;
use Spatie\LaravelData\Data;

class UserinfoDTO extends Data
{
public function __construct(
public string $sub,
public string $email,
public bool $email_verified,
public ?string $gender = null,
public ?string $company_id = null,
public ?string $company_name = null,
public ?string $given_name = null,
public ?string $locale = null,
public ?int $company_user_id = null,
public ?string $family_name = null,
) {}

public static function fromResponse(Response $response): self
{
if ($response->failed()) {
throw new Exception('Failed to create DTO from Response');
}
$data = $response->json();

return self::fromArray($data);
}

public static function fromArray(array $data): self
{
if (! $data) {
throw new Exception('Unable to create DTO. Data missing from response.');
}

return new self(
sub: Arr::get($data, 'sub'),
email: Arr::get($data, 'email'),
email_verified: Arr::get($data, 'email_verified'),
gender: Arr::get($data, 'gender'),
company_id: Arr::get($data, 'company_id'),
company_name: Arr::get($data, 'company_name'),
given_name: Arr::get($data, 'given_name'),
locale: Arr::get($data, 'locale'),
company_user_id: Arr::get($data, 'company_user_id'),
family_name: Arr::get($data, 'family_name'),
);
}
}
7 changes: 7 additions & 0 deletions src/Exceptions/UserinfoVerificationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace CodebarAg\Bexio\Exceptions;

use Exception;

class UserinfoVerificationException extends Exception {}
144 changes: 144 additions & 0 deletions src/Http/Controllers/BexioOAuthController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

namespace CodebarAg\Bexio\Http\Controllers;

use CodebarAg\Bexio\BexioConnector;
use CodebarAg\Bexio\Services\BexioOAuthService;
use CodebarAg\Bexio\Support\BexioOAuthExceptionHandler;
use CodebarAg\Bexio\Support\BexioOAuthTokenStore;
use CodebarAg\Bexio\Support\BexioOAuthViewBuilder;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Session;

class BexioOAuthController extends Controller
{
/**
* Inject dependencies for OAuth, token storage, connector, exception handler, and view builder.
*/
public function __construct(
private BexioOAuthService $bexioOAuthService,
private BexioOAuthTokenStore $bexioTokenStore,
private BexioConnector $bexioConnector,
private BexioOAuthExceptionHandler $bexioOAuthExceptionHandler,
private BexioOAuthViewBuilder $bexioOAuthViewBuilder,
) {}

/**
* Redirect the user to the Bexio authorization page.
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response
*/
public function redirect()
{
try {
$connector = $this->bexioConnector;
$appScopes = config('bexio.auth.scopes', []);
$authorizationUrl = $connector->getAuthorizationUrl($appScopes);
Session::put('bexio_oauth_state', $connector->getState());

return Redirect::away($authorizationUrl);
} catch (\Throwable $e) {
return $this->bexioOAuthExceptionHandler->render($e, 'redirect');
}
}

/**
* Handle Bexio OAuth2 callback, exchange code for tokens, and store them.
*
* @return \Illuminate\View\View|\Illuminate\Http\Response
*/
public function callback(Request $request)
{
if ($view = $this->handleBexioCallbackError($request)) {
return $view;
}

$state = $request->input('state');
$expectedState = Session::pull('bexio_oauth_state');
$code = $request->input('code');

if (! $code || ! $state || ! $expectedState) {
return $this->bexioOAuthViewBuilder->build(
'danger',
'Invalid OAuth Callback',
'Missing or invalid authorization code/state. Please start the connection process again.',
['url' => url('/'), 'label' => 'Back to Home'],
null,
400
);
}

try {
$authenticator = $this->bexioOAuthService->exchangeCodeForAuthenticator($code, $state, $expectedState);
} catch (\Throwable $e) {
return $this->bexioOAuthExceptionHandler->render($e, 'callback');
}

try {
$connector = $this->bexioConnector;
$userinfo = $this->bexioOAuthService->fetchUserinfo($authenticator, $connector);
$this->bexioOAuthService->verifyUserinfo($userinfo);
$this->bexioTokenStore->put($authenticator);

return $this->bexioOAuthViewBuilder->build(
'success',
'Bexio Connected!',
'Your Bexio account was successfully connected.',
['url' => url('/'), 'label' => 'Back to Home']
);
} catch (\Throwable $e) {
return $this->bexioOAuthExceptionHandler->render($e, 'callback');
}
}

/**
* Handle errors returned by Bexio during the OAuth callback/redirect process.
*
* This method processes error responses from Bexio when a user is redirected back to the application
* after approving or denying the OAuth authorization request. If the user cancels or Bexio returns an error,
* this method renders an appropriate user-facing error view.
*/
private function handleBexioCallbackError(Request $request): \Illuminate\View\View|\Illuminate\Http\Response|null
{
if ($request->has('error')) {
$error = $request->input('error');

if ($error === 'access_denied') {
return $this->bexioOAuthViewBuilder->build(
'warning',
'Bexio Connection Cancelled',
'You cancelled connecting your Bexio account.',
null,
[
[
'url' => url('/'),
'label' => 'Back to Home',
'class' => 'secondary',
],
[
'url' => route('bexio.oauth.redirect'),
'label' => 'Try Again',
'class' => 'primary',
],
],
200
);
}
$description = $request->input('error_description', 'Authorization was denied or failed.');
$status = $request->input('error') === 'access_denied' ? 400 : 500;

return $this->bexioOAuthViewBuilder->build(
'danger',
'Bexio OAuth2 Error',
$description,
['url' => url('/'), 'label' => 'Back to Home'],
null,
$status
);
}

return null;
}
}
Loading
Loading