clientId); } /** * Build the URL the browser is sent to, together with the one-time secrets * that must be stashed in the session to complete the flow. * * The verifier is what makes this PKCE: it never leaves our server, and * only its SHA-256 hash travels through the browser. An attacker who * intercepts the returned code cannot redeem it without the verifier. * * @param array|null $scopes * @return array{url: string, state: string, code_verifier: string} */ public function authorizationUrl(?array $scopes = null): array { $this->assertConfigured(); $state = Str::random(40); $verifier = Str::random(128); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); $query = http_build_query([ 'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUri, 'response_type' => 'code', 'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])), 'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256', ]); return [ 'url' => $this->server.config('pireon.endpoints.authorize').'?'.$query, 'state' => $state, 'code_verifier' => $verifier, ]; } /** * Redeem an authorization code for tokens. */ public function exchangeCode(string $code, string $codeVerifier): AccessToken { return $this->token([ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri, 'code_verifier' => $codeVerifier, ]); } /** * Resource owner password grant. Kept for automated tests and first-party * clients that cannot open a browser; gated by `pireon.password_grant_enabled`. * * @param array|null $scopes */ public function requestPasswordToken(string $username, string $password, ?array $scopes = null): AccessToken { if (! config('pireon.password_grant_enabled')) { throw new PireonException('The password grant is disabled for this application.', status: 400); } return $this->token([ 'grant_type' => 'password', 'username' => $username, 'password' => $password, 'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])), ]); } /** * Machine-to-machine token representing the application itself rather than * any user. Cached until shortly before it expires. * * @param array|null $scopes */ public function clientCredentialsToken(?array $scopes = null): AccessToken { $scopes ??= config('pireon.scopes', []); $key = 'pireon:cc:'.md5($this->clientId.'|'.implode(' ', $scopes)); $cached = Cache::get($key); if (is_array($cached)) { $token = AccessToken::fromArray($cached); if (! $token->hasExpired()) { return $token; } } $token = $this->token([ 'grant_type' => 'client_credentials', 'scope' => implode(' ', $scopes), ]); Cache::put($key, $token->toArray(), $token->expiresAt ?? now()->addMinutes(5)); return $token; } public function refresh(string $refreshToken): AccessToken { return $this->token([ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken, 'scope' => '', ]); } /** * Who does this access token belong to? Doubles as token validation: the * server answers 401 for a token that is expired, revoked, or not tied to * a user (as with client_credentials). */ public function identity(string $accessToken): PireonIdentity { $response = $this->request() ->withToken($accessToken) ->get($this->apiUrl(config('pireon.endpoints.user'))); if ($response->status() === 401) { throw new PireonException('The access token is not valid for any user.', status: 401); } $this->assertSuccessful($response); return PireonIdentity::fromResponse($response->json()); } /** * Call any endpoint of the identity server's REST API on behalf of a token. * Used by the optional remote-user screens. * * @param array $data */ public function call(string $accessToken, string $method, string $path, array $data = []): Response { $response = $this->request() ->withToken($accessToken) ->send(strtoupper($method), $this->apiUrl($path), [ strtoupper($method) === 'GET' ? 'query' : 'json' => $data, ]); $this->assertSuccessful($response); return $response; } /** * @param array $payload */ protected function token(array $payload): AccessToken { $this->assertConfigured(); $payload['client_id'] = $this->clientId; // Public clients (PKCE from a browser) have no secret. Confidential // clients must authenticate, so only send it when one is configured. if (filled($this->clientSecret)) { $payload['client_secret'] = $this->clientSecret; } $url = $this->server.config('pireon.endpoints.token'); try { $response = $this->request()->asForm()->post($url, $payload); } catch (ConnectionException $e) { throw PireonException::unreachable($url, $e); } if ($response->failed()) { throw PireonException::tokenRequestFailed($response->status(), (array) $response->json()); } return AccessToken::fromResponse((array) $response->json()); } protected function request(): PendingRequest { return Http::acceptJson() ->timeout((int) config('pireon.http_timeout', 10)) ->retry(2, 200, throw: false); } protected function apiUrl(string $path): string { return $this->server.config('pireon.api_prefix').'/'.ltrim($path, '/'); } protected function assertSuccessful(Response $response): void { if ($response->failed()) { throw new PireonException( "The identity server answered {$response->status()} for this request.", status: $response->status(), context: (array) $response->json(), ); } } protected function assertConfigured(): void { if (blank($this->clientId)) { throw PireonException::misconfigured('PIREON_CLIENT_ID'); } } }