ratespecial / logto-laravel
Logto.io OIDC support for Laravel
Requires
- php: ^8.3
- firebase/php-jwt: ^7.0
Requires (Dev)
- illuminate/console: ^11
- illuminate/support: ^11
- laravel/pint: ^1.0
- orchestra/testbench: ^9
- phpstan/phpstan: ^2.1.54
- phpunit/phpunit: ^11
README
Logto.io (OAuth/OIDC) support for Laravel. This package contributes two independent features:
logto-api-resourceguard — a Laravel auth guard that validates Logto-issued JWT access tokens and JIT-provisions users.- MCP protected-resource controller — an RFC 9728
/.well-known/oauth-protected-resourceendpoint that letslaravel/mcpuse Logto as its identity provider instead of Passport or Sanctum.
Requirements: PHP ^8.3, Laravel 11, a Logto tenant.
Table of Contents
- Installation
- Configuration
- High-level Architecture
- JIT User Provisioning
- Feature 1 — The
logto-api-resourceGuard - Feature 2 — MCP Protected Resource Controller
- Development
- License
Installation
composer require ratespecial/logto-laravel
The service provider is auto-registered via Laravel's package discovery.
Configuration
The package reads from both config/logto.php (published automatically via mergeConfigFrom) and config/services.php. Set these in your host app's .env:
LOGTO_ENDPOINT=https://your-tenant.logto.app LOGTO_API_RESOURCE=https://api.example.com LOGTO_CACHE_TTL=600 LOGTO_SUBJECT_COLUMN=logto_sub # MCP feature (off by default) LOGTO_MCP_ROUTES=true LOGTO_MCP_SCOPES="mcp:use"
| Env var | Config key | Default | Purpose |
|---|---|---|---|
LOGTO_ENDPOINT |
services.logto.endpoint |
— | Your Logto tenant URL. Required. |
LOGTO_API_RESOURCE |
services.logto.api-resource |
url('/') |
JWT audience this API accepts. Required. |
LOGTO_CACHE_TTL |
services.logto.cache-ttl |
600 |
TTL (seconds) for cached OIDC discovery + JWKS. |
LOGTO_SUBJECT_COLUMN |
logto.subject-column |
logto_sub |
User-model column that stores the JWT sub claim. |
LOGTO_MCP_ROUTES |
logto.mcp.routes |
false |
Enables the RFC 9728 discovery routes. |
LOGTO_MCP_SCOPES |
logto.mcp.scopes-supported |
mcp:use |
Space-delimited scopes advertised in the discovery metadata. |
LOGTO_MCP_PROTECTED_RESOURCE_MIDDLEWARE |
logto.mcp.protected-resource-middleware |
'' |
Comma-delimited middleware applied to the discovery route. |
The provider binds LogtoTokenValidator and OidcDiscoveryService from services.logto.*, so add these entries to config/services.php:
'logto' => [ 'endpoint' => env('LOGTO_ENDPOINT'), 'api-resource' => env('LOGTO_API_RESOURCE'), 'cache-ttl' => (int) env('LOGTO_CACHE_TTL', 600), ],
The JWT-claim → user-model attribute mapping defaults to email and name. Override logto.model-attributes by publishing the config or merging into it from a service provider.
High-level Architecture
flowchart LR
subgraph App[Laravel Host App]
Routes[Routes / MCP servers]
User[(users table)]
end
subgraph Lib[ratespecial/logto-laravel]
Guard[LogtoApiResourceGuard]
Validator[LogtoTokenValidator]
Discovery[OidcDiscoveryService]
MCPCtrl[OauthProtectedResourceController]
end
Logto[(Logto tenant)]
Routes -- auth:logto --> Guard
Guard --> Validator --> Discovery
Discovery -- JWKS + OIDC config --> Logto
Guard -- updateOrCreate / setOAuthScopes --> User
Routes -- .well-known/oauth-protected-resource --> MCPCtrl
MCPCtrl --> Discovery
Loading
JIT User Provisioning
This package uses just-in-time (JIT) user provisioning: there is no separate registration flow. The first time a given Logto identity (sub claim) presents a valid access token to your API, the guard calls updateOrCreate on your user model and inserts a new row keyed by logto.subject-column. On every subsequent request, the same row is found and updated with any mapped claim attributes (email, name, etc.).
The subject must be assigned at least one permission to the API resource in Logto. If they don't, the guard will reject them and the user will not be provisioned.
⚠️ Any token Logto has signed for your configured
LOGTO_API_RESOURCEaudience will result in a user record being created automatically the first time it's seen. There is no manual approval step. If you need to restrict who can sign in, enforce that in Logto (sign-in experience, roles, organization membership, or by minting tokens with specific scopes) — not in this package. You can also listen forUserProvisionedEventto audit or post-process new accounts.
Feature 1 — The logto-api-resource Guard
The guard:
- Reads the bearer token from the incoming request.
- Validates signature, issuer (
iss), and audience (aud) against Logto's OIDC discovery document and JWKS — both cached forLOGTO_CACHE_TTLseconds. - JIT-provisions a user via
updateOrCreate, keyed onlogto.subject-column. Attributes are mapped from JWT claims usinglogto.model-attributes. - Attaches the
scopeclaim to the in-memory user. A globalGate::beforehook then makescan:<scope>middleware and$user->can('<scope>')work transparently. - Dispatches
Ratespecial\Logto\Events\UserProvisionedEventthe first time a givensubis seen.
Request flow
sequenceDiagram
participant Client
participant App as Laravel App
participant Guard as LogtoApiResourceGuard
participant Validator as LogtoTokenValidator
participant Discovery as OidcDiscoveryService (cached)
participant Logto
participant DB
Client->>App: GET /api/... (Bearer <jwt>)
App->>Guard: auth:logto middleware
Guard->>Validator: validate(token)
Validator->>Discovery: getJwks() / issuer
alt cache miss
Discovery->>Logto: GET /.well-known/openid-configuration
Discovery->>Logto: GET jwks_uri
end
Discovery-->>Validator: keys + issuer
Validator-->>Guard: claims (sub, scope, ...)
Guard->>DB: updateOrCreate(logto_sub = claims.sub)
Guard-->>App: authenticated User with scopes
App-->>Client: 200 (or 401 if invalid)
Loading
Migrations
The user model must have a column matching logto.subject-column (default logto_sub). Two migration groups are publishable — pick the one that fits your project:
# Fresh project — full users table with the subject column included php artisan vendor:publish --tag=logto-migrations-users # OR — existing users table; just add the logto_sub column php artisan vendor:publish --tag=logto-migrations-logto-sub php artisan migrate
If you want a different column name, publish the migration, rename the column, and set LOGTO_SUBJECT_COLUMN to match.
User model
Your user model must use the HasOAuthScopes trait and implement the OAuthScopable contract so the guard can write scopes to it and the Gate::before hook can read them back.
namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Ratespecial\Logto\Contracts\OAuthScopable; use Ratespecial\Logto\HasOAuthScopes; class User extends Authenticatable implements OAuthScopable { use HasOAuthScopes; protected $fillable = ['name', 'email', 'logto_sub']; }
Guard registration
The service provider auto-merges an auth.guards.logto entry, but you still need to point its provider at one of your auth.providers.* entries. In config/auth.php:
'guards' => [ 'logto' => [ 'driver' => 'logto-api-resource', 'provider' => 'users', ], ], 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], ],
Protecting routes
use Illuminate\Support\Facades\Route; use App\Http\Controllers\OrderController; // Authenticate only Route::get('/api/me', fn () => auth('logto')->user()) ->middleware('auth:logto'); // Authenticate + require a Logto OAuth scope Route::get('/api/orders', [OrderController::class, 'index']) ->middleware(['auth:logto', 'can:orders:read']);
can:orders:read works because the service provider installs a Gate::before hook that delegates ability checks to $user->hasOAuthScope($ability). The same is true of programmatic checks like $user->can('orders:read') or Gate::allows('orders:read').
Reacting to new users
use Illuminate\Support\Facades\Event; use Ratespecial\Logto\Events\UserProvisionedEvent; Event::listen(function (UserProvisionedEvent $event) { // $event->user is the freshly-created Authenticatable // Send a welcome email, kick off onboarding, etc. });
Feature 2 — MCP Protected Resource Controller
laravel/mcp ships with first-class support for Laravel Passport. This package adds a parallel discovery endpoint so MCP clients (e.g. Claude Code) can authenticate against your Logto tenant instead.
It exposes RFC 9728 metadata at:
GET /.well-known/oauth-protected-resourceGET /.well-known/oauth-protected-resource/{path?}(namedmcp.oauth.protected-resource.nested)
laravel/mcp's AddWwwAuthenticateHeader middleware builds the WWW-Authenticate URL from the nested route name, so simply registering these routes wires the discovery handshake end-to-end. These endpoints must not sit behind authentication middleware — they're public discovery documents.
Note on the OAuth model. This integration does not use Dynamic Client Registration (DCR) or Client ID Metadata (CIMD). The MCP client uses a pre-registered OAuth client ID that you create up-front in Logto as a Third-party app. The client and its allowed redirect URIs must exist in Logto before the user adds the MCP server to their client.
Discovery handshake
sequenceDiagram
participant MCPClient as MCP Client (e.g. Claude Code)
participant App as Laravel App (laravel/mcp + this lib)
participant Logto
MCPClient->>App: POST /mcp (no token)
App-->>MCPClient: 401 + WWW-Authenticate: resource_metadata=".../.well-known/oauth-protected-resource/mcp"
MCPClient->>App: GET /.well-known/oauth-protected-resource/mcp
App-->>MCPClient: { resource, authorization_servers: [Logto issuer], scopes_supported }
MCPClient->>Logto: OAuth dance (authorize / token)
Logto-->>MCPClient: access_token (aud = LOGTO_API_RESOURCE)
MCPClient->>App: POST /mcp (Bearer <jwt>)
App->>App: auth:logto guard validates token
App-->>MCPClient: 200 MCP response
Loading
Logto configuration
Before any MCP client can connect, set up the OAuth client in Logto:
- In the Logto admin console, create a new Third-party app for each MCP client you want to support (e.g. one for Claude Code, one for Cursor, etc.).
- Set the application type to Single Page App.
- The generated App ID is what the MCP client will use as its OAuth Client ID.
- Under the third-party app's Redirect URIs, pre-register every loopback callback URL the client will use, including the exact port — e.g.
http://localhost:55910/callback. Logto will reject the OAuth flow if the callback URI doesn't match an entry here, so the port has to be picked up-front and reused when the MCP server is added on the client side. - Grant the third-party app permission to request your API resource (the value of
LOGTO_API_RESOURCE) and any scopes fromLOGTO_MCP_SCOPES.
Enable the discovery routes
LOGTO_MCP_ROUTES=true LOGTO_MCP_SCOPES="mcp:use"
When logto.mcp.routes is true, the service provider loads routes/mcp-routes.php, which registers both /.well-known/oauth-protected-resource endpoints. The advertised scopes_supported array comes from LOGTO_MCP_SCOPES (space-delimited).
Protecting an Mcp::web server with the Logto guard
In routes/ai.php:
use Laravel\Mcp\Facades\Mcp; use App\Mcp\Servers\MyServer; Mcp::web('/mcp', MyServer::class) ->middleware(['auth:logto', 'can:mcp:use']);
auth:logtoruns the bearer token throughLogtoApiResourceGuard.can:mcp:useenforces a Logto OAuth scope via the sameGate::beforehook the guard installs —mcp:usehere is whatever scope you defined in Logto and advertised inLOGTO_MCP_SCOPES.- On a missing or invalid token,
laravel/mcp'sAddWwwAuthenticateHeaderinjects theWWW-Authenticateheader pointing at this library's nested discovery route. No extra wiring required.
Adding the MCP server to Claude Code
Because the OAuth client and its callback URI are pre-registered in Logto, the user has to pass both the Client ID (the Logto third-party App ID) and the callback port (matching one of the redirect URIs registered in Logto) when they add the MCP server:
claude mcp add \
--transport http \
--client-id your-3rdparty-app-id \
--callback-port 55910 \
yourservice https://domain.com/mcp
The discovery handshake then kicks in automatically — Claude Code hits /mcp, gets a 401 with the WWW-Authenticate header, fetches /.well-known/oauth-protected-resource/mcp, and runs the OAuth flow against the Logto issuer returned in the metadata using the supplied client ID and callback port.
Development
QA is wired through Composer scripts:
composer qa # fix-style → phpstan → test composer test # PHPUnit (orchestra/testbench, in-memory SQLite) composer check-style # Laravel Pint --test composer phpstan # PHPStan level 6
License
MIT.