webfox / laravel-xero-oauth2

A Laravel integration for Xero using the Oauth 2.0 spec
MIT License
50 stars 32 forks source link

Issue creating custom credential manager, with resolution. #100

Closed morris14 closed 3 months ago

morris14 commented 3 months ago

I was reading through the docs and was having a hard time adding my own credential manager. Rather than storing the tokens in a local xero.json file, I wanted to store tokens against the Company model in my application, allowing each company to have their own connection.

I used the suggested method to bind the new credential manager to the container:

$this->app->bind(OauthCredentialManager::class, function(Application $app) {
    return new UserStorageProvider(
        \Auth::user(), // Storage Mechanism 
        $app->make('session.store'), // Used for storing/retrieving oauth 2 "state" for redirects
        $app->make(\Webfox\Xero\Oauth2Provider::class) // Used for getting redirect url and refreshing token
    );
});

but was having issues when the token expired every half hour, the token was never refreshing. Here is an updated method to bind the credential manager to the container. Essentially, the below code checks and refreshes the token if needed (like how the FileStore::class is registered within this package).

$this->app->bind(OauthCredentialManager::class, function (Application $app) {
    $storageMechanism = session()->get('current_company_id') ?
        Company::find(session()->get('current_company_id')) :
        auth()->user()->companies()->first();

    $credentials = new CompanyStorageProvider(
        $storageMechanism,
        $app->make('session.store'),
        $app->make(\Webfox\Xero\Oauth2Provider::class)
    );

    if ($credentials->exists() && $credentials->isExpired()) {
        $credentials->refresh();
    }

    return $credentials;
});

Hope this helps anyone having the same issue!

JamesFreeman commented 3 months ago

Hey, I've done this in my app - we switch xero context based on Client

Replacing container instances, with my own.

class Xero
{
    public static function switchInstance(Client $client)
    {
        app()->singleton(Oauth2Provider::class, function (Application $app) {
            return new Oauth2Provider([
                'clientId' => config('xero.oauth.client_id'),
                'clientSecret' => config('xero.oauth.client_secret'),
                'redirectUri' => route('xero.auth.callback'),
                'urlAuthorize' => config('xero.oauth.url_authorize'),
                'urlAccessToken' => config('xero.oauth.url_access_token'),
                'urlResourceOwnerDetails' => config('xero.oauth.url_resource_owner_details'),
            ]);
        });

        app()->bind(OauthCredentialManager::class, function (Application $app) use ($client) {
            $credentials = new ClientStorageProvider(
                $client, // Storage Mechanism
                $app->make('session.store'), // Used for storing/retrieving oauth 2 "state" for redirects
                $app->make(Oauth2Provider::class) // Used for getting redirect url and refreshing token
            );

            if ($credentials->exists() && $credentials->isExpired()) {
                $credentials->refresh();
            }

            return $credentials;
        });
    }
}

Creating my own Storage Provider (note, the store/delete/data methods)

class ClientStorageProvider implements OauthCredentialManager
{
    public function __construct(protected Client $client, protected Store $session, protected Oauth2Provider $oauthProvider)
    {
    }

    public function getAccessToken(): string
    {
        return $this->data('token');
    }

    public function getRefreshToken(): string
    {
        return $this->data('refresh_token');
    }

    public function getTenants(): ?array
    {
        return $this->data('tenants');
    }

    public function getTenantId(int $tenant = 0): string
    {
        if ($this->data('tenant_id') && ! $this->data('tenants')) {
            return $this->data('tenant_id');
        }

        if (! isset($this->data('tenants')[$tenant])) {
            throw new \Exception('No such tenant exists');
        }

        return $this->data('tenants')[$tenant]['Id'];
    }

    public function getExpires(): int
    {
        return $this->data('expires');
    }

    public function getState(): string
    {
        return $this->session->get('xero_oauth2_state');
    }

    public function getAuthorizationUrl(): string
    {
        $redirectUrl = $this->oauthProvider->getAuthorizationUrl([
            'scope' => config('xero.oauth.scopes'),
            'redirect_uri' => route('xero.auth.callback'),
        ]);

        $this->session->put('xero_oauth2_state', $this->oauthProvider->getState());

        return $redirectUrl;
    }

    public function getData(): array
    {
        return $this->data();
    }

    public function exists(): bool
    {
        return (bool) $this->client->xero_oauth;
    }

    public function isExpired(): bool
    {
        return time() >= $this->data('expires');
    }

    public function refresh(): void
    {
        $newAccessToken = $this->oauthProvider->getAccessToken('refresh_token', [
            'grant_type' => 'refresh_token',
            'refresh_token' => $this->getRefreshToken(),
        ]);

        $this->store($newAccessToken);
    }

    public function store(AccessTokenInterface $token, ?array $tenants = null): void
    {
        $this->client->xero_oauth = [
            'token' => $token->getToken(),
            'refresh_token' => $token->getRefreshToken(),
            'id_token' => $token->getValues()['id_token'],
            'expires' => $token->getExpires(),
            'tenants' => $tenants ?? $this->getTenants(),
        ];

        $this->client->saveOrFail();
    }

    public function delete(): void
    {
        $this->client->xero_oauth = null;
        $this->client->saveOrFail();
    }

    public function getUser(): ?array
    {
        try {
            $jwt = new JWTClaims();
            $jwt->setTokenId($this->data('id_token'));
            $decodedToken = $jwt->decode();

            return [
                'given_name' => $decodedToken->getGivenName(),
                'family_name' => $decodedToken->getFamilyName(),
                'email' => $decodedToken->getEmail(),
                'user_id' => $decodedToken->getXeroUserId(),
                'username' => $decodedToken->getPreferredUsername(),
                'session_id' => $decodedToken->getGlobalSessionId(),
            ];
        } catch (\Throwable $e) {
            return null;
        }
    }

    protected function data($key = null)
    {
        if (! $this->exists()) {
            throw new \Exception('Xero oauth credentials are missing');
        }

        $cacheData = $this->client->xero_oauth;

        return empty($key) ? $cacheData : ($cacheData[$key] ?? null);
    }
}

I then have a middleware that I call when I want to switch the xero instance for the client per route (you can in theory just add this in AppServiceProvider

Middleware

class XeroTokensFromUserAuth
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next): \Symfony\Component\HttpFoundation\Response
    {
        $xero = app(Xero::class);

        if (auth()->check()) {
            $xero->switchInstance(auth()->user()->client);
        }

        return $next($request);
    }
}

Give me a shout if you have any questions.