NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Git for Laravel

Scoped tokens & installations

An installation token with no scope reaches every repository the app is installed on, for an hour. Narrow it per operation instead — this is the point of minting rather than storing a credential:

use RoundlyConsulting\Git\Dto\Credentials\GithubAppToken;
use RoundlyConsulting\Git\Dto\Input\InstallationTokenScope;

$scoped = GithubAppToken::for(appId: $id, installationId: $installation, privateKey: $key)
    ->forScope(InstallationTokenScope::forRepositories(repositoryIds: ['40823311']));

Git::github($scoped)->repo('acme/api')->cloneUrl('acme', $scoped);

// Or select by name — equally narrow:
InstallationTokenScope::forRepositories(repositories: ['acme/api', 'acme/web']);
  • forRepositories() takes its permission set from git.providers.github.app.permissions, so call sites can’t drift from what the app was granted.
  • repositoryIds (numeric, survives a rename) and repositories (names) are equally narrow; a non-numeric id throws InvalidCredentialsException.
  • forScope() returns a new credential — the original is never narrowed behind someone else’s back.
  • The token cache is keyed per scope, so a scoped mint can never be served a wider cached token.

Deliberately wide scopes

An empty scope is refused outright — a scope that names no repository would mint a token for every repository in the installation, so the package throws rather than silently widening. Two operations genuinely cannot name a repository, and each has a scope that is as wide as it must be and as weak as it can be:

  • InstallationTokenScope::metadataOnly() — every repository, metadata: read only. Use it to ask which repositories an installation has.
  • InstallationTokenScope::administrationOnly() — every repository, administration: write only. Use it to create a repository, mint it just-in-time, and never store, log or forward it.
Git::github($credentials->forScope(InstallationTokenScope::metadataOnly()))
    ->installationRepositories();          // Page<Repository>

Git::github($credentials->forScope(InstallationTokenScope::metadataOnly()))
    ->allInstallationRepositories();       // LazyCollection<Repository>

Passing no scope at all means everything the installation granted, everywhere — almost never what you want. A scope the installation cannot satisfy (unknown repository, ungranted permission) and a vanished installation raise InvalidCredentialsException; a 403 (rate limit, suspension) stays a RequestException, because consumers treat the former as reconnect-required.

Acting as the app

/app/** endpoints authenticate with the app’s own JWT rather than an installation token — that is how you verify an installation id before trusting it, for instance one that arrived from a browser redirect. Git::githubApp() reads git.providers.github.app.id and private_key and throws InvalidCredentialsException naming the missing key when either is absent:

$installation = Git::githubApp()->installations()->find($installationIdFromTheRedirect);

$installation->accountLogin;            // "acme-inc"
$installation->accountType;             // e.g. "Organization"
$installation->repositorySelection;     // "all" | "selected"
$installation->permissions;             // ['contents' => 'write', ...]
$installation->reachesEveryRepository();
$installation->isSuspended();

Git::github($credentials)->installationRepositories(); // NOT /user/repos: an
                                                       // installation token 403s there

The other app-JWT lookups — every account the app is installed on, and finding an installation by account rather than by id (useful when a customer reinstalls and the stored id goes stale):

$installations = Git::githubApp()->installations();

$installations->all();                       // Page<Installation>
$installations->forOrganization('acme-inc'); // Installation
$installations->forUser('jane-doe');         // Installation

The /app/** endpoints require app credentials and the installation endpoints require an installation credential; using the wrong one raises the package’s own InvalidCredentialsException rather than GitHub’s opaque 403. All of them are callable on the Provider type without an instanceof, drivable through Git::fake(), and throw FeatureNotSupportedException on GitLab and Bitbucket.

Installing the app

Send a person to install the app with installations()->installUrl() (it needs app.slug). GitHub echoes state back to the app’s Setup URL alongside installation_id, which ties the returning redirect to the request that left:

$url = Git::githubApp()->installations()->installUrl($state); // https://github.com/apps/<slug>/installations/new?state=…

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.