Laravel AT Protocol Client (alpha & unstable)
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add OAuth client metadata and JWKS support with customization hooks

+473
+2
composer.json
··· 25 25 "web-token/jwt-core": "^3.0", 26 26 "web-token/jwt-signature": "^3.0", 27 27 "web-token/jwt-key-mgmt": "^3.0", 28 + "phpseclib/phpseclib": "^3.0", 29 + "firebase/php-jwt": "^6.0", 28 30 "socialdept/atp-schema": "^0.2", 29 31 "socialdept/atp-resolver": "^0.1" 30 32 },
+30
config/atp-client.php
··· 53 53 54 54 /* 55 55 |-------------------------------------------------------------------------- 56 + | OAuth Configuration 57 + |-------------------------------------------------------------------------- 58 + | 59 + | OAuth 2.0 settings for AT Protocol authentication. The private key is 60 + | used for signing client assertions. Generate a key with: 61 + | php artisan atp-client:generate-key 62 + | 63 + | The metadata endpoints are automatically available at: 64 + | - GET /atp/oauth/client-metadata.json 65 + | - GET /atp/oauth/jwks.json 66 + | - GET /.well-known/oauth-client-metadata 67 + | 68 + */ 69 + 'oauth' => [ 70 + 'disabled' => env('ATP_OAUTH_DISABLED', false), 71 + 'prefix' => env('ATP_OAUTH_PREFIX', '/atp/oauth/'), 72 + 'private_key' => env('ATP_OAUTH_PRIVATE_KEY'), 73 + 'scope' => env('ATP_OAUTH_SCOPE', 'atproto transition:generic'), 74 + 75 + 'client_metadata' => [ 76 + 'client_name' => env('ATP_CLIENT_NAME', config('app.name')), 77 + 'client_uri' => env('ATP_CLIENT_URL', config('app.url')), 78 + 'logo_uri' => env('ATP_CLIENT_LOGO_URI'), 79 + 'tos_uri' => env('ATP_CLIENT_TOS_URI'), 80 + 'policy_uri' => env('ATP_CLIENT_POLICY_URI'), 81 + ], 82 + ], 83 + 84 + /* 85 + |-------------------------------------------------------------------------- 56 86 | HTTP Settings 57 87 |-------------------------------------------------------------------------- 58 88 |
+31
src/AtpClientServiceProvider.php
··· 2 2 3 3 namespace SocialDept\AtpClient; 4 4 5 + use Illuminate\Support\Facades\Route; 5 6 use Illuminate\Support\ServiceProvider; 6 7 use SocialDept\AtpClient\Auth\ClientMetadataManager; 7 8 use SocialDept\AtpClient\Auth\DPoPKeyManager; ··· 105 106 $this->publishes([ 106 107 __DIR__.'/../config/atp-client.php' => config_path('atp-client.php'), 107 108 ], 'atp-client-config'); 109 + 110 + $this->commands([ 111 + \SocialDept\AtpClient\Console\GenerateOAuthKeyCommand::class, 112 + ]); 108 113 } 114 + 115 + $this->registerRoutes(); 116 + } 117 + 118 + /** 119 + * Register OAuth metadata routes 120 + */ 121 + protected function registerRoutes(): void 122 + { 123 + if (config('atp-client.oauth.disabled')) { 124 + return; 125 + } 126 + 127 + $prefix = config('atp-client.oauth.prefix', '/atp/oauth/'); 128 + 129 + Route::prefix($prefix)->group(function () { 130 + Route::get('client-metadata.json', \SocialDept\AtpClient\Http\Controllers\ClientMetadataController::class) 131 + ->name('atp.oauth.client-metadata'); 132 + 133 + Route::get('jwks.json', \SocialDept\AtpClient\Http\Controllers\JwksController::class) 134 + ->name('atp.oauth.jwks'); 135 + }); 136 + 137 + // Register standard .well-known endpoint 138 + Route::get('.well-known/oauth-client-metadata', \SocialDept\AtpClient\Http\Controllers\ClientMetadataController::class) 139 + ->name('atp.oauth.well-known'); 109 140 } 110 141 111 142 /**
+21
src/Auth/OAuthKey.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use InvalidArgumentException; 6 + use SocialDept\AtpClient\Crypto\P256; 7 + 8 + class OAuthKey extends P256 9 + { 10 + /** 11 + * Load OAuth key from configuration 12 + */ 13 + public static function load(?string $private = null): static 14 + { 15 + $private ??= config('atp-client.oauth.private_key'); 16 + 17 + throw_if(empty($private), InvalidArgumentException::class, 'OAuth private key not configured. Run: php artisan atp-client:generate-key'); 18 + 19 + return parent::load($private); 20 + } 21 + }
+33
src/Console/GenerateOAuthKeyCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Console; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\AtpClient\Auth\OAuthKey; 7 + 8 + class GenerateOAuthKeyCommand extends Command 9 + { 10 + protected $signature = 'atp-client:generate-key'; 11 + 12 + protected $description = 'Generate a new ES256 private key for OAuth'; 13 + 14 + public function handle(): int 15 + { 16 + $this->info('Generating new ES256 private key...'); 17 + $this->newLine(); 18 + 19 + $key = OAuthKey::create(); 20 + $private = $key->privateB64(); 21 + 22 + $this->components->twoColumnDetail('Private Key', '<fg=yellow>'.$private.'</>'); 23 + $this->newLine(); 24 + 25 + $this->components->info('Add this to your .env file:'); 26 + $this->line('ATP_OAUTH_PRIVATE_KEY="'.$private.'"'); 27 + $this->newLine(); 28 + 29 + $this->components->warn('Keep this key secret! Do not commit it to version control.'); 30 + 31 + return self::SUCCESS; 32 + } 33 + }
+75
src/Crypto/AbstractKeypair.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Crypto; 4 + 5 + use phpseclib3\Crypt\EC; 6 + use phpseclib3\Crypt\EC\PrivateKey; 7 + use phpseclib3\Crypt\EC\PublicKey; 8 + use phpseclib3\Crypt\PublicKeyLoader; 9 + 10 + abstract class AbstractKeypair 11 + { 12 + const CURVE = ''; 13 + 14 + const ALG = ''; 15 + 16 + const MULTIBASE_PREFIX = ''; 17 + 18 + protected PrivateKey|PublicKey $key; 19 + 20 + /** 21 + * Create a new keypair 22 + */ 23 + public static function create(): static 24 + { 25 + $self = new static; 26 + $self->key = EC::createKey(static::CURVE); 27 + 28 + return $self; 29 + } 30 + 31 + /** 32 + * Load keypair from base64-encoded private key 33 + */ 34 + public static function load(?string $private = null): static 35 + { 36 + $self = new static; 37 + $self->key = PublicKeyLoader::load( 38 + base64_decode(strtr($private, '-_', '+/'), strict: true) 39 + ); 40 + 41 + return $self; 42 + } 43 + 44 + /** 45 + * Get the key instance 46 + */ 47 + public function key(): PrivateKey|PublicKey 48 + { 49 + return $this->key; 50 + } 51 + 52 + /** 53 + * Export private key as base64-encoded string 54 + */ 55 + public function privateB64(): string 56 + { 57 + return strtr(base64_encode($this->key->toString('PKCS8')), '+/', '-_'); 58 + } 59 + 60 + /** 61 + * Export private key as PEM string 62 + */ 63 + public function toPEM(): string 64 + { 65 + return $this->key->toString('PKCS8'); 66 + } 67 + 68 + /** 69 + * Convert to JSON Web Key format 70 + */ 71 + public function toJWK(): JsonWebKey 72 + { 73 + return new JsonWebKey($this->key); 74 + } 75 + }
+103
src/Crypto/JsonWebKey.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Crypto; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Contracts\Support\Jsonable; 7 + use phpseclib3\Crypt\EC\PrivateKey; 8 + use phpseclib3\Crypt\EC\PublicKey; 9 + use Stringable; 10 + 11 + class JsonWebKey implements Arrayable, Jsonable, Stringable 12 + { 13 + protected PrivateKey|PublicKey $key; 14 + 15 + protected string $kid = 'key-1'; 16 + 17 + public function __construct(PrivateKey|PublicKey $key) 18 + { 19 + $this->key = $key; 20 + } 21 + 22 + /** 23 + * Get the underlying key 24 + */ 25 + public function key(): PrivateKey|PublicKey 26 + { 27 + return $this->key; 28 + } 29 + 30 + /** 31 + * Get public key version 32 + */ 33 + public function asPublic(): static 34 + { 35 + if ($this->key instanceof PrivateKey) { 36 + $clone = clone $this; 37 + $clone->key = $this->key->getPublicKey(); 38 + 39 + return $clone; 40 + } 41 + 42 + return $this; 43 + } 44 + 45 + /** 46 + * Get key ID 47 + */ 48 + public function kid(): string 49 + { 50 + return $this->kid; 51 + } 52 + 53 + /** 54 + * Set key ID 55 + */ 56 + public function withKid(string $kid): static 57 + { 58 + $this->kid = $kid; 59 + 60 + return $this; 61 + } 62 + 63 + /** 64 + * Export as PEM 65 + */ 66 + public function toPEM(): string 67 + { 68 + return $this->key->toString('PKCS8'); 69 + } 70 + 71 + /** 72 + * Convert to array (JWK format) 73 + */ 74 + public function toArray(): array 75 + { 76 + $jwk = $this->key->getPublicKey()->toString('JWK'); 77 + 78 + return array_merge( 79 + json_decode($jwk, true), 80 + [ 81 + 'alg' => P256::ALG, 82 + 'use' => 'sig', 83 + 'kid' => $this->kid(), 84 + ] 85 + ); 86 + } 87 + 88 + /** 89 + * Convert to JSON 90 + */ 91 + public function toJson($options = 0): string 92 + { 93 + return json_encode($this->toArray(), JSON_THROW_ON_ERROR | $options); 94 + } 95 + 96 + /** 97 + * Convert to string 98 + */ 99 + public function __toString(): string 100 + { 101 + return $this->toJson(); 102 + } 103 + }
+61
src/Crypto/JsonWebKeySet.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Crypto; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Contracts\Support\Jsonable; 7 + use Stringable; 8 + 9 + class JsonWebKeySet implements Arrayable, Jsonable, Stringable 10 + { 11 + /** 12 + * @var array<JsonWebKey> 13 + */ 14 + protected array $keys = []; 15 + 16 + /** 17 + * Load default JWKS from OAuthKey 18 + */ 19 + public static function load(): static 20 + { 21 + $key = \SocialDept\AtpClient\Auth\OAuthKey::load(); 22 + 23 + return (new static)->addKey($key->toJWK()->asPublic()); 24 + } 25 + 26 + /** 27 + * Add a key to the set 28 + */ 29 + public function addKey(JsonWebKey $key): static 30 + { 31 + $this->keys[] = $key; 32 + 33 + return $this; 34 + } 35 + 36 + /** 37 + * Convert to array (JWKS format) 38 + */ 39 + public function toArray(): array 40 + { 41 + return [ 42 + 'keys' => collect($this->keys)->map(fn (JsonWebKey $key) => $key->toArray())->toArray(), 43 + ]; 44 + } 45 + 46 + /** 47 + * Convert to JSON 48 + */ 49 + public function toJson($options = 0): string 50 + { 51 + return json_encode($this->toArray(), JSON_THROW_ON_ERROR | $options); 52 + } 53 + 54 + /** 55 + * Convert to string 56 + */ 57 + public function __toString(): string 58 + { 59 + return $this->toJson(); 60 + } 61 + }
+12
src/Crypto/P256.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Crypto; 4 + 5 + class P256 extends AbstractKeypair 6 + { 7 + const CURVE = 'secp256r1'; 8 + 9 + const ALG = 'ES256'; 10 + 11 + const MULTIBASE_PREFIX = "\x80\x24"; 12 + }
+71
src/Http/Config/OAuthMetadata.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http\Config; 4 + 5 + use Closure; 6 + use SocialDept\AtpClient\Crypto\JsonWebKeySet; 7 + 8 + class OAuthMetadata 9 + { 10 + protected static ?Closure $clientMetadataUsing = null; 11 + 12 + protected static ?Closure $jwksUsing = null; 13 + 14 + /** 15 + * Set custom callback for client metadata 16 + */ 17 + public static function clientMetadataUsing(?Closure $callback): void 18 + { 19 + static::$clientMetadataUsing = $callback; 20 + } 21 + 22 + /** 23 + * Get client metadata as array 24 + */ 25 + public static function clientMetadata(): array 26 + { 27 + // Get custom metadata from callback if set 28 + if (static::$clientMetadataUsing) { 29 + $stored = call_user_func(static::$clientMetadataUsing); 30 + } else { 31 + $stored = config('atp-client.oauth.client_metadata', []); 32 + } 33 + 34 + // Base metadata that should always be present 35 + $base = [ 36 + 'client_id' => route('atp.oauth.client-metadata'), 37 + 'jwks_uri' => route('atp.oauth.jwks'), 38 + 'redirect_uris' => config('atp-client.client.redirect_uris', []), 39 + 'scope' => config('atp-client.oauth.scope', 'atproto transition:generic'), 40 + 'grant_types' => ['authorization_code', 'refresh_token'], 41 + 'response_types' => ['code'], 42 + 'token_endpoint_auth_method' => 'private_key_jwt', 43 + 'token_endpoint_auth_signing_alg' => 'ES256', 44 + 'application_type' => 'web', 45 + 'dpop_bound_access_tokens' => true, 46 + ]; 47 + 48 + // Merge and filter out null values 49 + return array_filter(array_merge($stored, $base), fn ($value) => ! is_null($value)); 50 + } 51 + 52 + /** 53 + * Set custom callback for JWKS 54 + */ 55 + public static function jwksUsing(?Closure $callback): void 56 + { 57 + static::$jwksUsing = $callback; 58 + } 59 + 60 + /** 61 + * Get JWKS as array 62 + */ 63 + public static function jwks(): array 64 + { 65 + if (static::$jwksUsing) { 66 + return call_user_func(static::$jwksUsing); 67 + } 68 + 69 + return JsonWebKeySet::load()->toArray(); 70 + } 71 + }
+17
src/Http/Controllers/ClientMetadataController.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http\Controllers; 4 + 5 + use Illuminate\Http\JsonResponse; 6 + use SocialDept\AtpClient\Http\Config\OAuthMetadata; 7 + 8 + class ClientMetadataController 9 + { 10 + /** 11 + * Return OAuth client metadata 12 + */ 13 + public function __invoke(): JsonResponse 14 + { 15 + return response()->json(OAuthMetadata::clientMetadata()); 16 + } 17 + }
+17
src/Http/Controllers/JwksController.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http\Controllers; 4 + 5 + use Illuminate\Http\JsonResponse; 6 + use SocialDept\AtpClient\Http\Config\OAuthMetadata; 7 + 8 + class JwksController 9 + { 10 + /** 11 + * Return JWKS (JSON Web Key Set) 12 + */ 13 + public function __invoke(): JsonResponse 14 + { 15 + return response()->json(OAuthMetadata::jwks()); 16 + } 17 + }