{"id":51540540,"url":"https://github.com/sandermuller/socialite-solana","last_synced_at":"2026-07-09T13:01:44.215Z","repository":{"id":357200578,"uuid":"1235727471","full_name":"SanderMuller/socialite-solana","owner":"SanderMuller","description":"Laravel Socialite provider for Sign-In With Solana (SIWS / CAIP-122)","archived":false,"fork":false,"pushed_at":"2026-05-11T20:20:47.000Z","size":112,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-11T20:30:36.112Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/SanderMuller.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-11T15:42:24.000Z","updated_at":"2026-05-11T20:21:07.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/SanderMuller/socialite-solana","commit_stats":null,"previous_names":["sandermuller/socialite-solana"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/SanderMuller/socialite-solana","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Fsocialite-solana","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Fsocialite-solana/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Fsocialite-solana/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Fsocialite-solana/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/SanderMuller","download_url":"https://codeload.github.com/SanderMuller/socialite-solana/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/SanderMuller%2Fsocialite-solana/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35299763,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-09T13:01:43.314Z","updated_at":"2026-07-09T13:01:44.205Z","avatar_url":"https://github.com/SanderMuller.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Socialite Solana\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/sandermuller/socialite-solana.svg?style=flat-square)](https://packagist.org/packages/sandermuller/socialite-solana)\n[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/sandermuller/socialite-solana/run-tests.yml?branch=main\u0026label=tests\u0026style=flat-square)](https://github.com/sandermuller/socialite-solana/actions/workflows/run-tests.yml)\n[![Total Downloads](https://img.shields.io/packagist/dt/sandermuller/socialite-solana.svg?style=flat-square)](https://packagist.org/packages/sandermuller/socialite-solana)\n[![License](https://img.shields.io/packagist/l/sandermuller/socialite-solana.svg?style=flat-square)](LICENSE)\n\nLaravel Socialite provider for Sign-In With Solana (SIWS / CAIP-122).\n\nThis package adds a `solana` driver to [Laravel Socialite](https://laravel.com/docs/socialite). The wallet user signs a SIWS challenge message with their private key; the server verifies the Ed25519 signature and you get back a Socialite `User` keyed by the base58 public key.\n\n- CAIP-122 / Phantom SIWS challenge format (domain, statement, URI, chain ID, nonce, issued-at, expiration-time, resources)\n- Ed25519 verification via [`sandermuller/solana-pubkey`](https://github.com/SanderMuller/solana-pubkey)\n- Single-use nonce with configurable TTL — failed verifies don't burn the challenge\n- Accepts base58 or base64 signatures\n- Typed exception hierarchy so callers can map each failure case to its own UX\n- Works from controllers, Livewire components, queue jobs, or console code — the HTTP wrappers are thin\n- Pluggable challenge storage — session-backed default, cache-backed for API / Sanctum bearer-token flows, or your own implementation\n- Optional PSR-3 logger injection for ops dashboards on failed-signature / expiry / malformed-input rates\n\n## Requirements\n\nPHP 8.3+ and Laravel 11, 12, or 13. The `ext-sodium` PHP extension must be enabled.\n\n## Installation\n\n```bash\ncomposer require sandermuller/socialite-solana\n```\n\nRegister the Socialite extension listener in `app/Providers/AppServiceProvider.php::boot()` (works on Laravel 11/12/13 — those skeletons no longer ship an `EventServiceProvider`):\n\n```php\nuse Illuminate\\Support\\Facades\\Event;\nuse SanderMuller\\SocialiteSolana\\SocialiteSolanaExtendSocialite;\nuse SocialiteProviders\\Manager\\SocialiteWasCalled;\n\npublic function boot(): void\n{\n    Event::listen(\n        SocialiteWasCalled::class,\n        [SocialiteSolanaExtendSocialite::class, 'handle'],\n    );\n}\n```\n\nOn older Laravel apps that still have `app/Providers/EventServiceProvider.php`, the listener may instead live in the `$listen` array — the `Event::listen()` call above works on all supported versions.\n\n## Configuration\n\nAdd the `solana` block to `config/services.php`:\n\n```php\n'solana' =\u003e [\n    // Required by socialiteproviders/manager but unused by this driver.\n    'client_id' =\u003e env('SOLANA_CLIENT_ID', 'unused'),\n    'client_secret' =\u003e env('SOLANA_CLIENT_SECRET', 'unused'),\n    'redirect' =\u003e env('SOLANA_REDIRECT_URI', '/auth/solana/callback'),\n\n    // Domain shown in the SIWS message. Defaults to APP_URL host.\n    'domain' =\u003e env('SOLANA_SIWS_DOMAIN'),\n\n    // Canonical URI of the resource being signed in to. Defaults to APP_URL.\n    'uri' =\u003e env('SOLANA_SIWS_URI'),\n\n    // Human-readable statement (optional).\n    'statement' =\u003e env('SOLANA_SIWS_STATEMENT', 'Sign in to authenticate.'),\n\n    // Solana cluster: mainnet | devnet | testnet | localnet.\n    'chain' =\u003e env('SOLANA_SIWS_CHAIN', 'mainnet'),\n\n    // Challenge lifetime in seconds (minimum 60). Default 180 matches typical\n    // SIWS reference implementations.\n    'ttl' =\u003e (int) env('SOLANA_SIWS_TTL', 180),\n\n    // Optional CAIP-122 resource URIs (list of strings).\n    'resources' =\u003e [],\n\n    // Where the issued challenge lives between buildChallengeFor() and\n    // verifyCredentials(). Values: 'session' (default), 'cache', or an FQCN\n    // implementing SanderMuller\\SocialiteSolana\\Contracts\\ChallengeStore.\n    'store' =\u003e env('SOLANA_CHALLENGE_STORE', 'session'),\n],\n```\n\n## Usage\n\nThe Solana flow is not a redirect-based OAuth flow. The wallet returns a signed message synchronously, so the provider exposes both HTTP wrappers and framework-agnostic methods:\n\n| Method | Signature | When to use |\n|---|---|---|\n| `challenge()` | `Socialite::driver('solana')-\u003echallenge(): JsonResponse` | Controller routes; reads `publicKey` from the request, returns JSON. |\n| `user()` | `Socialite::driver('solana')-\u003euser(): User` | Controller routes; reads `publicKey`, `signature`, `message`, `nonce` from the request. |\n| `buildChallengeFor(string $publicKey)` | returns `array{message: string, nonce: string}` | Livewire, queue, console — no HTTP request needed. |\n| `verifyCredentials(string $publicKey, string $signature, string $message, string $nonce)` | returns `User` | Livewire, queue, console — no HTTP request needed. |\n\nCalling `redirect()` throws `BadMethodCallException` — there's nothing to redirect to. Calling `scopes()` with non-empty input also throws; OAuth scopes are not applicable.\n\n### Controller routes\n\n```php\nuse Illuminate\\Support\\Facades\\Auth;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Support\\Facades\\Route;\nuse Illuminate\\Support\\Str;\nuse Laravel\\Socialite\\Facades\\Socialite;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\SolanaAuthException;\n\nRoute::post('/auth/solana/challenge', function () {\n    return Socialite::driver('solana')-\u003echallenge();\n})-\u003emiddleware('web');\n\nRoute::post('/auth/solana/callback', function () {\n    try {\n        $solanaUser = Socialite::driver('solana')-\u003euser();\n    } catch (SolanaAuthException $e) {\n        return response()-\u003ejson(['error' =\u003e $e-\u003egetMessage()], 422);\n    }\n\n    $user = \\App\\Models\\User::firstOrCreate(\n        ['solana_public_key' =\u003e $solanaUser-\u003egetId()],\n        ['password' =\u003e Hash::make(Str::random(32))],\n    );\n\n    Auth::login($user, remember: true);\n\n    return response()-\u003ejson(['redirect' =\u003e '/home']);\n})-\u003emiddleware('web');\n```\n\n### Livewire component\n\n```php\nuse Laravel\\Socialite\\Facades\\Socialite;\nuse Livewire\\Component;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\SolanaAuthException;\n\nclass SolanaLogin extends Component\n{\n    public string $walletAddress = '';\n\n    /**\n     * Issue a SIWS challenge. The JS layer should `$wire.set('walletAddress', pubkey)`\n     * before calling this so the component already knows the address when verify() fires.\n     */\n    public function requestSignInChallenge(string $publicKey): array\n    {\n        return Socialite::driver('solana')-\u003ebuildChallengeFor($publicKey);\n    }\n\n    public function signIn(string $signature, string $message, string $nonce): void\n    {\n        try {\n            $solanaUser = Socialite::driver('solana')-\u003everifyCredentials(\n                $this-\u003ewalletAddress, $signature, $message, $nonce,\n            );\n        } catch (SolanaAuthException $e) {\n            $this-\u003eaddError('solana', $e-\u003egetMessage());\n            return;\n        }\n\n        // ... resolve user, log in, redirect\n    }\n}\n```\n\nOn the JS side, a small `signMessageBase58()` helper wraps the wallet adapter call so consumers don't repeat `bs58.encode(signed.signature)` at every site:\n\n```js\nimport bs58 from 'https://esm.sh/bs58@5.0.0';\n\nasync function signMessageBase58(wallet, message) {\n    const encoded = new TextEncoder().encode(message);\n    const signed = await wallet.signMessage(encoded, 'utf8');\n    return bs58.encode(signed.signature);\n}\n\nawait wallet.connect();\n$wire.set('walletAddress', wallet.publicKey.toBase58());\n\nconst { message, nonce } = await $wire.call('requestSignInChallenge', wallet.publicKey.toBase58());\nconst signature = await signMessageBase58(wallet, message);\nawait $wire.call('signIn', signature, message, nonce);\n```\n\nFor UX, sharing a single error surface between sync (server validation) and async (wallet/network) errors keeps the component simple:\n\n```blade\n\u003cdiv x-data=\"{ error: null }\"\u003e\n    \u003cdiv x-show=\"error || $wire.__instance.errors?.solana\" class=\"alert\"\u003e\n        \u003cspan x-text=\"error\"\u003e\u003c/span\u003e\n        @error('solana') {{ $message }} @enderror\n    \u003c/div\u003e\n\u003c/div\u003e\n```\n\n### Granular error handling\n\nEvery authentication failure throws a specific subclass of `SanderMuller\\SocialiteSolana\\Exceptions\\SolanaAuthException`, which itself extends `\\InvalidArgumentException`. Catch the base class for a uniform 422, or the subclasses for per-case UX and rate-limit buckets:\n\n```php\nuse SanderMuller\\SocialiteSolana\\Exceptions\\AddressMismatchException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\ChallengeExpiredException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\ChallengeNotFoundException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\InvalidPublicKeyException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\InvalidSignatureException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\MalformedSignatureException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\MessageMismatchException;\nuse SanderMuller\\SocialiteSolana\\Exceptions\\MissingChallengeParameterException;\n\ntry {\n    $solanaUser = Socialite::driver('solana')-\u003euser();\n} catch (MissingChallengeParameterException) {\n    return back()-\u003ewithErrors(['wallet' =\u003e 'Wallet response was incomplete. Try again.']);\n} catch (ChallengeExpiredException | ChallengeNotFoundException) {\n    return back()-\u003ewithErrors(['wallet' =\u003e 'Sign-in window expired. Click sign in to retry.']);\n} catch (InvalidPublicKeyException) {\n    return back()-\u003ewithErrors(['wallet' =\u003e 'That wallet address is not a valid Solana public key.']);\n} catch (MessageMismatchException | AddressMismatchException) {\n    abort(400, 'Tampered request.'); // Wallet signed something the server did not issue — usually a bug, occasionally an attack.\n} catch (MalformedSignatureException) {\n    // Wallet returned a signature that's not 64 raw bytes — extension bug or wrong wallet. Catch BEFORE InvalidSignatureException.\n    return back()-\u003ewithErrors(['wallet' =\u003e 'Your wallet did not return a valid signature. Try a different wallet or reconnect.']);\n} catch (InvalidSignatureException) {\n    return back()-\u003ewithErrors(['wallet' =\u003e 'Signature did not match. The user may have switched wallets mid-flow. Please try again.']);\n}\n```\n\n### Challenge storage\n\nBy default, the issued challenge lives in the Laravel session, so the browser's session cookie binds it to one device. For headless flows (Sanctum bearer tokens, native mobile, API clients) where no session exists, switch to the cache-backed store:\n\n```php\n// config/services.php\n'solana' =\u003e [\n    // ...\n    'store' =\u003e 'cache',\n],\n```\n\nThe cache store treats the 32-character nonce (~160 bits of entropy) as the unguessable handle, exactly the way short-lived bearer tokens work. Callers must keep the nonce secret between issue and verify.\n\nFor full control, implement `SanderMuller\\SocialiteSolana\\Contracts\\ChallengeStore` and bind it in the container — the package prefers a container binding over the config string:\n\n```php\nuse SanderMuller\\SocialiteSolana\\Contracts\\ChallengeStore;\n\n$this-\u003eapp-\u003esingleton(ChallengeStore::class, MyRedisStore::class);\n```\n\nThe interface is three methods:\n\n```php\ninterface ChallengeStore\n{\n    public function put(Challenge $challenge): void;\n    public function find(string $nonce): ?Challenge;\n    public function forget(string $nonce): void;\n}\n```\n\n### Logging\n\nThe Provider accepts a PSR-3 `LoggerInterface` so you can ship SIWS auth events to your observability stack — useful for dashboards on failed-signature count, expiry rate, malformed-pubkey rate, and the like.\n\nResolution order:\n\n1. An instance passed via `setLogger()` wins.\n2. Otherwise a container-bound `Psr\\Log\\LoggerInterface` is used.\n3. Otherwise `NullLogger`.\n\nThe **recommended pattern is a scoped `setLogger()` call inside a small helper**, not a global container binding. The Socialite manager memoizes drivers, so calling `setLogger()` on `Socialite::driver('solana')` once per request is idempotent across `challenge()` / `user()` / `buildChallengeFor()` / `verifyCredentials()` invocations:\n\n```php\nuse Illuminate\\Support\\Facades\\Log;\nuse Laravel\\Socialite\\Facades\\Socialite;\nuse SanderMuller\\SocialiteSolana\\Provider;\n\nprivate function solanaProvider(): Provider\n{\n    /** @var Provider $provider */\n    $provider = Socialite::driver('solana');\n\n    $provider-\u003esetLogger(Log::channel('security'));\n\n    return $provider;\n}\n\n// Use it like:\n$user = $this-\u003esolanaProvider()-\u003euser();\n```\n\n\u003e [!WARNING]\n\u003e Binding `Psr\\Log\\LoggerInterface` globally in the container works, but it swaps the default logger for **every** package that resolves that contract — including Laravel internals — which is a wide blast radius for one provider's worth of observability. Prefer the scoped helper pattern above unless you genuinely want the routing to apply package-wide.\n\n```php\n// Avoid unless you actually want the routing to be app-wide:\n$this-\u003eapp-\u003ebind(\\Psr\\Log\\LoggerInterface::class, fn () =\u003e Log::channel('security'));\n```\n\nEach failure throws **and** logs a `warning` with the exception class in `context.exception` plus relevant non-PII details (signature byte length, expiry delta, missing-param flags). Successful challenge issuance and signature verification log at `info`.\n\n| Event | Level | Notable context |\n|---|---|---|\n| Challenge issued | `info` | `ttl_seconds` |\n| Verification succeeded | `info` | — |\n| Missing param on challenge / verify | `warning` | `exception`, `*_empty` flags |\n| Invalid public key | `warning` | `exception`, `input_length` |\n| Malformed / unknown nonce | `warning` | `exception`, `nonce_length`, `reason` |\n| Message mismatch | `warning` | `exception`, `stored_length`, `received_length` |\n| Address mismatch | `warning` | `exception` |\n| Challenge expired | `warning` | `exception`, `expired_seconds_ago` |\n| Malformed signature (undecodable or wrong length) | `warning` | `exception` = `MalformedSignatureException`, `signature_byte_length` or `input_length` |\n| Invalid signature (verify returned false) | `warning` | `exception` = `InvalidSignatureException` |\n| Nonce race lost | `warning` | `exception`, `reason: concurrent_consumption` |\n\nNo public keys, signatures, or message contents are logged.\n\n### Frontend (Phantom wallet)\n\nThe wallet connects first so the server knows which address to embed in the SIWS message:\n\n```js\nimport bs58 from 'https://esm.sh/bs58@5.0.0';\n\nconst provider = window.solana;\nawait provider.connect();\nconst publicKey = provider.publicKey.toBase58();\n\nconst challenge = await fetch('/auth/solana/challenge', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },\n    credentials: 'same-origin',\n    body: JSON.stringify({ publicKey }),\n}).then(r =\u003e r.json());\n\nconst signed = await provider.signMessage(\n    new TextEncoder().encode(challenge.message),\n    'utf8',\n);\n\nconst result = await fetch('/auth/solana/callback', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },\n    credentials: 'same-origin',\n    body: JSON.stringify({\n        publicKey,\n        signature: bs58.encode(signed.signature),\n        message: challenge.message,\n        nonce: challenge.nonce,\n    }),\n});\n```\n\nA full Blade example lives in `resources/views/auth/solana-login.blade.php`.\n\n### What the SIWS message looks like\n\n```\nexample.com wants you to sign in with your Solana account:\nFh7s4WkPgVZBkU3xCqAd9kQp...\n\nSign in to authenticate.\n\nURI: https://example.com\nVersion: 1\nChain ID: mainnet\nNonce: 9b2c1f4a...\nIssued At: 2026-05-11T12:30:00Z\nExpiration Time: 2026-05-11T12:40:00Z\n```\n\n## Threat model\n\nWhat the package defends against, and what it doesn't.\n\n**Defends against:**\n\n- **Forged signatures** — Ed25519 verify with the SDK's length-checked binary signature path.\n- **Replay across sessions (session store)** — when using the default `SessionChallengeStore`, the challenge is keyed by the Laravel session cookie; an attacker without the victim's session cannot consume their challenge.\n- **Replay of a redeemed nonce** — nonce is consumed atomically by the store's `forget()`, which returns `true` only for the caller that actually removed it. Concurrent verifiers on the same valid bundle resolve to exactly one success; the losers get `ChallengeNotFoundException`.\n- **Expired challenges** — `Expiration Time` is enforced server-side; `ttl` config has a 60-second floor.\n- **Address swap** — `publicKey` in the request must match the address the server embedded in the issued message.\n- **Tampered message** — server compares the received message against the stored message with `hash_equals` before signature verify.\n- **Malformed nonce** — input nonce must match the 32-char alphanumeric format the package issues; bogus values short-circuit to `ChallengeNotFoundException`.\n- **Nonce burn on failed verify** — failed signature verifies do NOT consume the nonce, so wallet retries and double-submits survive bad input.\n\n**Does NOT defend against:**\n\n- **Wallet-side phishing** — if the user signs the message on an attacker's domain, the package on your domain is not involved. Educate users; consider showing the domain field prominently in the wallet prompt.\n- **Compromised session storage** — the challenge lives in the Laravel session. If the session driver is compromised (XSS leaking the cookie, shared session store breach), an attacker can race a valid signature into your endpoint within the TTL window.\n- **Wallet identity over time** — the package authenticates that whoever signed the message controls the wallet right now. It does not say anything about whether the same human still controls that wallet a week later. Treat the wallet address like any other long-lived identity claim and re-authenticate when the action warrants it.\n- **Bundle interception with the cache store** — `CacheChallengeStore` removes the session-cookie binding, so the 32-character nonce is the only handle. If an attacker can read the full `(publicKey, signature, message, nonce)` bundle off the wire before the legitimate client redeems it, they can redeem it from any client within the TTL. The signature itself stays valid only against the address embedded in the message — they cannot impersonate a different wallet — but they can race the legitimate caller for the one-time consumption. This is the conventional bearer-token threat model. Use the cache store only on TLS-terminated transports, and prefer the session store whenever a session cookie is available.\n\n## Testing\n\n```bash\ncomposer test\n```\n\nThis runs the Pest suite under Orchestra Testbench. Covers the full SIWS round-trip, every exception subclass, signature/message/address tampering, nonce format validation, nonce reuse, expiry, and base58 vs base64 signature decoding.\n\n## Contributing\n\nIssues and pull requests welcome at \u003chttps://github.com/SanderMuller/socialite-solana\u003e.\n\nQuality gates on every PR: Pint, PHPStan (level max, strict rules, type-coverage 100%), Rector, full Pest suite.\n\n## Credits\n\n- [Sander Muller](https://github.com/SanderMuller)\n- [Claude Opus 4.7](https://claude.com/claude-code)\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsandermuller%2Fsocialite-solana","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsandermuller%2Fsocialite-solana","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsandermuller%2Fsocialite-solana/lists"}