locator = $locator; } public function handleAuthorizeRequest($params) { if ($params['client_id'] === null) { throw new BadRequestException("Client id is required"); } if ($params['redirect_uri'] === null) { throw new BadRequestException("Redirect URI is required"); } $client = $this->locator->getBrowserBasedOAuth2ClientById($params['client_id']); if ($client->redirectUrl !== $params['redirect_uri']) { throw new BadRequestException("Invalid client redirect URI: " . $params['redirect_uri']); } $alg = $params['alg']; if ($alg === null) { $alg = "RS256"; } $state = $params['state']; $nonce = $params['nonce']; if ($state === null && $nonce === null) { throw new BadRequestException("State or nonce is required"); } // Storing OAuth2 data in session $oauth2Data = new OAuth2RequestData(); $oauth2Data->clientId = $client->client; $oauth2Data->redirectUrl = $client->redirectUrl; $oauth2Data->state = $state; $oauth2Data->nonce = $nonce; $scope = $params['scope']; if ($scope !== null) { $oauth2Data->scope = explode(' ', $scope); } $session = $this->locator->getSession(); $session->setOAuth2RequestData($oauth2Data); } public function getRedirectResponseUrl(): string { $session = $this->locator->getSession(); $code = base64_encode(bin2hex(openssl_random_pseudo_bytes(64))); $tokenData = new AccessTokenData(); // Code is stored in hashed format inside the database, as a basic // security measure in order to prevent issues in case of data breach. $tokenData->codeHash = hash('sha256', $code); $tokenData->userId = $session->getUser()->id; $tokenData->clientId = $session->getOAuth2RequestData()->clientId; $tokenData->redirectUri = $session->getOAuth2RequestData()->redirectUrl; $tokenData->scope = $session->getOAuth2RequestData()->scope; $this->locator->getAccessTokenDAO()->createTokenData($tokenData); $state = $session->getOAuth2RequestData()->state; $nonce = $session->getOAuth2RequestData()->nonce; if ($state !== null) { // Authorization code grant flow $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . '?code=' . $code; $scope = $tokenData->scope; if ($scope !== null && count($scope) > 0) { $redirectUrl .= '&scope=' . implode("%20", $scope); } $redirectUrl .= '&state=' . $state; } else { // Implicit grant flow $idToken = $this->locator->getTokenBuilder()->getIdToken($tokenData, function(& $jwt) use($nonce) { $jwt['nonce'] = $nonce; }); $redirectUrl = $session->getOAuth2RequestData()->redirectUrl . "#id_token=" . $idToken; } return $redirectUrl; } public function handleAccessTokenRequest(array $params, array $headers): array { if ($params['grant_type'] === null) { throw new \RAP\BadRequestException("grant_type is required"); } switch ($params['grant_type']) { case "authorization_code": return $this->handleGetTokenFromCodeRequest($params, $headers); case "client_credentials": return $this->handleClientCredentialsRequest($headers); case "refresh_token": return $this->handleRefreshTokenRequest($params, $headers); case "urn:ietf:params:oauth:grant-type:token-exchange": return $this->locator->getTokenExchanger()->exchangeToken($params, $headers); default: throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']); } } private function handleGetTokenFromCodeRequest(array $params, array $headers): array { $this->locator->getClientAuthChecker()->validateClientAuth($headers); if ($params['code'] === null) { throw new BadRequestException("code id is required"); } if ($params['redirect_uri'] === null) { throw new BadRequestException("Redirect URI is required"); } // Note: theorically the standard wants also the client_id here, // however some clients don't send it (e.g. Spring Security library) // $codeHash = hash('sha256', $params['code']); $tokenData = $this->locator->getAccessTokenDAO()->retrieveTokenDataFromCode($codeHash); if ($tokenData === null) { throw new BadRequestException("No token for given code"); } if ($tokenData->redirectUri !== $params['redirect_uri']) { throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']); } $response = $this->getAccessTokenResponse($tokenData); $this->locator->getAccessTokenDAO()->deleteTokenData($codeHash); return $response; } private function handleClientCredentialsRequest(array $headers): array { $client = $this->locator->getClientAuthChecker()->validateCliClientAuth($headers); $accessTokenData = new AccessTokenData(); $accessTokenData->clientId = $client->id; $accessTokenData->userId = $client->id; $accessTokenData->scope = $client->scope; $accessTokenData->audience = $client->audience; return $this->getAccessTokenResponse($accessTokenData, false); } private function handleRefreshTokenRequest(array $params, array $headers): array { $this->locator->getClientAuthChecker()->validateClientAuth($headers); if ($params['refresh_token'] === null) { throw new BadRequestException("refresh_token is required"); } $tokenHash = hash('sha256', $params['refresh_token']); $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshTokenData($tokenHash); if ($refreshToken === null || $refreshToken->isExpired()) { throw new UnauthorizedException("Invalid refresh token"); } $scope = $this->getScope($params, $refreshToken); // Generating a new access token $accessTokenData = new AccessTokenData(); $accessTokenData->clientId = $refreshToken->clientId; $accessTokenData->userId = $refreshToken->userId; $accessTokenData->scope = $scope; $accessTokenData = $this->locator->getAccessTokenDAO()->createTokenData($accessTokenData); return $this->getAccessTokenResponse($accessTokenData); } /** * We can request a new access token with a scope that is a subset (or the * same set) of the scope defined for the refresh token. */ private function getScope(array $params, RefreshTokenData $refreshToken): ?array { $scope = $refreshToken->scope; if ($params['scope'] !== null) { $newScopeValues = explode(' ', $params['scope']); foreach ($newScopeValues as $newScopeValue) { $found = false; foreach ($scope as $oldScopeValue) { if ($oldScopeValue === $newScopeValue) { $found = true; break; } } if (!$found) { throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token"); } } $scope = $newScopeValues; } return $scope; } private function getAccessTokenResponse(AccessTokenData $tokenData, bool $refreshToken = true) { $result = []; $result['access_token'] = $this->locator->getTokenBuilder()->getAccessToken($tokenData); $result['token_type'] = 'Bearer'; $result['expires_in'] = $tokenData->expirationTime - time(); if ($refreshToken) { $result['refresh_token'] = $this->buildRefreshToken($tokenData); } if ($tokenData->scope !== null && in_array('openid', $tokenData->scope)) { $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); } return $result; } /** * Token introspection endpoint shouldn't be necessary when using OIDC (since * tokens are self-contained JWT). This function is kept here for compatibility * with some libraries (e.g. Spring Security) but it could be removed in the * future. */ public function handleCheckTokenRequest(array $headers): array { $jwt = $this->locator->getTokenChecker()->validateToken(); $tokenData = $this->getTokenDataFromJwtObject($jwt); $result = []; $result['exp'] = $tokenData->expirationTime - time(); $result['user_name'] = $tokenData->userId; $result['client_id'] = $tokenData->clientId; // copy received access token $result['access_token'] = explode(" ", $headers['Authorization'])[1]; $result['refresh_token'] = $this->buildRefreshToken($tokenData); if (isset($tokenData->scope) && count($tokenData->scope) > 0) { $result['scope'] = $tokenData->scope; if (in_array('openid', $tokenData->scope)) { $result['id_token'] = $this->locator->getTokenBuilder()->getIdToken($tokenData); } } return $result; } private function getTokenDataFromJwtObject($jwt): AccessTokenData { $tokenData = new AccessTokenData(); $tokenData->clientId = $this->getClientIdFromAudience($jwt); $tokenData->userId = $jwt->sub; $tokenData->creationTime = $jwt->iat; $tokenData->expirationTime = $jwt->exp; $tokenData->scope = explode(' ', $jwt->scope); return $tokenData; } private function getClientIdFromAudience(object $jwt): string { if (!(isset($jwt->aud))) { throw new UnauthorizedException("Missing 'aud' claim in token"); } $audience = $jwt->aud; if (is_array($audience)) { if (count($audience) === 0) { throw new UnauthorizedException("Token has empty audience"); } return $audience[0]; } return $audience; } private function buildRefreshToken(AccessTokenData $tokenData): string { $refreshToken = base64_encode(bin2hex(openssl_random_pseudo_bytes(128))); $refreshTokenHash = hash('sha256', $refreshToken); $this->storeRefreshTokenData($tokenData, $refreshTokenHash); return $refreshToken; } private function storeRefreshTokenData(AccessTokenData $accessTokenData, string $refreshTokenHash): void { $refreshToken = new RefreshTokenData(); $refreshToken->tokenHash = $refreshTokenHash; $refreshToken->clientId = $accessTokenData->clientId; $refreshToken->userId = $accessTokenData->userId; $refreshToken->scope = $accessTokenData->scope; $this->locator->getRefreshTokenDAO()->createRefreshTokenData($refreshToken); } }